magic-agent 2.4.3 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +14 -3
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
/* knowledge_test.go — 「发现 → 知识库」(OpenViking)的行为钉住:
|
|
4
|
+
* ① 清单只从服务端那张表里来,客户端传什么都在表里找;
|
|
5
|
+
* ② 安装动作只跑服务端构造好的 argv,**不接受客户端传命令**;
|
|
6
|
+
* ③ 状态如实:没装就说没装,版本读不到就留空(不编),查不到最新版本也不报错卡住;
|
|
7
|
+
* ④ 可升级的判定要真的比较版本号,且预发布版不提示升级。
|
|
8
|
+
|
|
9
|
+
* ⚠️ 这些用例**不打外网、不真装 openviking**:PyPI 查询与 python 探测都换成桩。 */
|
|
10
|
+
|
|
11
|
+
import (
|
|
12
|
+
"bufio"
|
|
13
|
+
"encoding/json"
|
|
14
|
+
"net/http"
|
|
15
|
+
"net/http/httptest"
|
|
16
|
+
"os"
|
|
17
|
+
"path/filepath"
|
|
18
|
+
"runtime"
|
|
19
|
+
"strings"
|
|
20
|
+
"testing"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// fakeKnowledgeEnv 造一个「家目录 + 干净的 PATH」的临时环境。
|
|
24
|
+
//
|
|
25
|
+
// ⚠️ PATH 必须**只留临时 binDir**、不能追加原 PATH:本机真的装了 OpenViking
|
|
26
|
+
// (`C:\Python313\Scripts\ov.exe` 在 PATH 上),追加原 PATH 会让「PATH 上没有 ov」
|
|
27
|
+
// 这条断言永远不成立 —— 而且那不是代码的问题,是本机的真实状态。
|
|
28
|
+
func fakeKnowledgeEnv(t *testing.T) (home string, binDir string) {
|
|
29
|
+
t.Helper()
|
|
30
|
+
root := t.TempDir()
|
|
31
|
+
home = filepath.Join(root, "home")
|
|
32
|
+
binDir = filepath.Join(root, "bin")
|
|
33
|
+
for _, d := range []string{home, binDir} {
|
|
34
|
+
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
t.Setenv("PATH", binDir)
|
|
39
|
+
t.Setenv(ovEnvBin, "")
|
|
40
|
+
t.Setenv(ovEnvPython, "")
|
|
41
|
+
|
|
42
|
+
oldHome := ovHomeDir
|
|
43
|
+
oldProbe := ovPythonProbe
|
|
44
|
+
oldPyPI := pypiLatest
|
|
45
|
+
t.Cleanup(func() {
|
|
46
|
+
ovHomeDir = oldHome
|
|
47
|
+
ovPythonProbe = oldProbe
|
|
48
|
+
pypiLatest = oldPyPI
|
|
49
|
+
})
|
|
50
|
+
ovHomeDir = func() (string, error) { return home, nil }
|
|
51
|
+
// python 探测打桩:假装系统里有 3.13(够 ≥3.10 就行),但**不真的建 venv**。
|
|
52
|
+
ovPythonProbe = func() ([]string, string) { return []string{"python3"}, "3.13.3" }
|
|
53
|
+
pypiLatest = func(pkg string) (string, error) { return "", nil }
|
|
54
|
+
return home, binDir
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// writeFakeOV 在 PATH 上放一个假 `ov`。
|
|
58
|
+
//
|
|
59
|
+
// ⚠️ Windows 上必须带 .exe 后缀:Go 的 LookPath 对无扩展名的文件会按 PATHEXT
|
|
60
|
+
// 逐个加后缀去找,不会直接用裸名 —— 裸名文件在 Windows 上永远找不到。
|
|
61
|
+
func writeFakeOV(t *testing.T, binDir, name, content string) string {
|
|
62
|
+
t.Helper()
|
|
63
|
+
if runtime.GOOS == "windows" && filepath.Ext(name) == "" {
|
|
64
|
+
name += ".exe"
|
|
65
|
+
}
|
|
66
|
+
p := filepath.Join(binDir, name)
|
|
67
|
+
if err := os.WriteFile(p, []byte(content), 0o755); err != nil {
|
|
68
|
+
t.Fatal(err)
|
|
69
|
+
}
|
|
70
|
+
return p
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func knowledgeService() *Service { return &Service{opts: Options{}} }
|
|
74
|
+
|
|
75
|
+
/* ── 清单 ── */
|
|
76
|
+
|
|
77
|
+
func TestKnowledgeListsCatalog(t *testing.T) {
|
|
78
|
+
fakeKnowledgeEnv(t)
|
|
79
|
+
rec := httptest.NewRecorder()
|
|
80
|
+
knowledgeService().handleKnowledge(rec, httptest.NewRequest(http.MethodGet, "/desk/knowledge", nil))
|
|
81
|
+
if rec.Code != http.StatusOK {
|
|
82
|
+
t.Fatalf("状态码 = %d,期望 200", rec.Code)
|
|
83
|
+
}
|
|
84
|
+
var body struct {
|
|
85
|
+
Knowledge []knowledgeInfo `json:"knowledge"`
|
|
86
|
+
}
|
|
87
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
88
|
+
t.Fatalf("响应不是合法 JSON:%v(%s)", err, rec.Body.String())
|
|
89
|
+
}
|
|
90
|
+
if len(body.Knowledge) != 1 {
|
|
91
|
+
t.Fatalf("知识库数 = %d,期望 1(openviking)", len(body.Knowledge))
|
|
92
|
+
}
|
|
93
|
+
k := body.Knowledge[0]
|
|
94
|
+
if k.ID != "openviking" {
|
|
95
|
+
t.Fatalf("id = %q,期望 openviking", k.ID)
|
|
96
|
+
}
|
|
97
|
+
// PATH 上没有 → 必须如实说没装,而不是给一个看起来成功的空壳
|
|
98
|
+
if k.Installed {
|
|
99
|
+
t.Fatal("PATH 上没有 ov 时 Installed 应为 false")
|
|
100
|
+
}
|
|
101
|
+
if k.Tagline == "" || k.Description == "" || len(k.Examples) == 0 {
|
|
102
|
+
t.Fatal("介绍三件套(一句话简介 / 详细说明 / 示例)不能空 —— 发现页要展示它们")
|
|
103
|
+
}
|
|
104
|
+
if !strings.Contains(k.UsePrompt, "知识库") {
|
|
105
|
+
t.Fatal("让它干活的预填提示里要点明用途")
|
|
106
|
+
}
|
|
107
|
+
if k.Note == "" {
|
|
108
|
+
t.Fatal("必须有一句 note 讲清「为什么是现在这个状态」")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ── 探测:环境变量 / venv / PATH 三档 ── */
|
|
113
|
+
|
|
114
|
+
// 环境变量指定的命令优先级最高(用户显式指定)。
|
|
115
|
+
func TestKnowledgeDetectsEnvOverride(t *testing.T) {
|
|
116
|
+
home, binDir := fakeKnowledgeEnv(t)
|
|
117
|
+
custom := writeFakeOV(t, binDir, "my-ov", "#!/bin/sh\n")
|
|
118
|
+
t.Setenv(ovEnvBin, custom)
|
|
119
|
+
|
|
120
|
+
d := detectOpenViking()
|
|
121
|
+
if d.Bin != custom {
|
|
122
|
+
t.Fatalf("bin = %q,期望 %q", d.Bin, custom)
|
|
123
|
+
}
|
|
124
|
+
if d.Source != "env" {
|
|
125
|
+
t.Fatalf("source = %q,期望 env", d.Source)
|
|
126
|
+
}
|
|
127
|
+
_ = home
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 自建 venv 里的命令**不在 PATH 上** —— 一键安装装进 venv 后 `ov` 就不在 PATH,
|
|
131
|
+
// 所以 venv 这档必须排在 PATH 之前,否则「刚装好」会被误报成「未安装」。
|
|
132
|
+
func TestKnowledgeDetectsVenvBeforePath(t *testing.T) {
|
|
133
|
+
home, binDir := fakeKnowledgeEnv(t)
|
|
134
|
+
// PATH 上放一个同名命令:它应该被 venv 里那个**盖掉**(venv 优先)。
|
|
135
|
+
writeFakeOV(t, binDir, "ov", "#!/bin/sh\n")
|
|
136
|
+
|
|
137
|
+
venvBin := filepath.Join(ovVenvBinDir(filepath.Join(home, ovVenvName)), "ov")
|
|
138
|
+
if runtime.GOOS == "windows" {
|
|
139
|
+
venvBin += ".exe"
|
|
140
|
+
}
|
|
141
|
+
if err := os.MkdirAll(filepath.Dir(venvBin), 0o755); err != nil {
|
|
142
|
+
t.Fatal(err)
|
|
143
|
+
}
|
|
144
|
+
if err := os.WriteFile(venvBin, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
145
|
+
t.Fatal(err)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
d := detectOpenViking()
|
|
149
|
+
if d.Source != "venv" {
|
|
150
|
+
t.Fatalf("source = %q,期望 venv(venv 里的命令不在 PATH 上,必须先认它)", d.Source)
|
|
151
|
+
}
|
|
152
|
+
if d.Bin != venvBin {
|
|
153
|
+
t.Fatalf("bin = %q,期望 %q", d.Bin, venvBin)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 两个命令名都认:0.5.2 的文档给 `ov`,0.4.15 的文档给 `openviking`。
|
|
158
|
+
// 认错的后果是「明明装了却说没装」。
|
|
159
|
+
func TestKnowledgeAcceptsBothCLINames(t *testing.T) {
|
|
160
|
+
_, binDir := fakeKnowledgeEnv(t)
|
|
161
|
+
for _, name := range ovCLINames {
|
|
162
|
+
t.Run(name, func(t *testing.T) {
|
|
163
|
+
_, binDir := fakeKnowledgeEnv(t)
|
|
164
|
+
p := writeFakeOV(t, binDir, name, "#!/bin/sh\n")
|
|
165
|
+
d := detectOpenViking()
|
|
166
|
+
if d.Bin != p {
|
|
167
|
+
t.Fatalf("bin = %q,期望 %q(命令名 %s 应当被认)", d.Bin, p, name)
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
_ = binDir
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ── 版本号:分层兜底 ── */
|
|
175
|
+
|
|
176
|
+
// L1:CLI 自己说(`--version`)。
|
|
177
|
+
func TestKnowledgeVersionFromCLI(t *testing.T) {
|
|
178
|
+
if runtime.GOOS == "windows" {
|
|
179
|
+
t.Skip("本用例用 shell 脚本桩,Windows 上跑不了")
|
|
180
|
+
}
|
|
181
|
+
_, binDir := fakeKnowledgeEnv(t)
|
|
182
|
+
bin := writeFakeOV(t, binDir, "ov", "#!/bin/sh\necho 'ov 0.5.2'\n")
|
|
183
|
+
if got := versionFromCLI(bin); got != "0.5.2" {
|
|
184
|
+
t.Fatalf("版本 = %q,期望 0.5.2", got)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// L2:从 dist-info 目录名读(纯文件系统,不依赖解释器)。
|
|
189
|
+
// 这层是「CLI 没有 --version」时的主路径 —— `ov --version` 是否被支持并未确认。
|
|
190
|
+
func TestKnowledgeVersionFromDistInfo(t *testing.T) {
|
|
191
|
+
home, _ := fakeKnowledgeEnv(t)
|
|
192
|
+
venv := filepath.Join(home, ovVenvName)
|
|
193
|
+
site := filepath.Join(venv, "Lib", "site-packages")
|
|
194
|
+
if runtime.GOOS != "windows" {
|
|
195
|
+
site = filepath.Join(venv, "lib", "python3.12", "site-packages")
|
|
196
|
+
}
|
|
197
|
+
dist := filepath.Join(site, "openviking-0.4.15.dist-info")
|
|
198
|
+
if err := os.MkdirAll(dist, 0o755); err != nil {
|
|
199
|
+
t.Fatal(err)
|
|
200
|
+
}
|
|
201
|
+
if err := os.WriteFile(filepath.Join(dist, "METADATA"), []byte("Name: openviking\nVersion: 0.4.15\n"), 0o644); err != nil {
|
|
202
|
+
t.Fatal(err)
|
|
203
|
+
}
|
|
204
|
+
// 放一个空的 bin(不传 bin 也行,但传了更贴近真实形态)
|
|
205
|
+
bin := filepath.Join(ovVenvBinDir(venv), "ov")
|
|
206
|
+
if runtime.GOOS == "windows" {
|
|
207
|
+
bin += ".exe"
|
|
208
|
+
}
|
|
209
|
+
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
|
|
210
|
+
t.Fatal(err)
|
|
211
|
+
}
|
|
212
|
+
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
213
|
+
t.Fatal(err)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
got := versionFromDistInfo(bin, venv)
|
|
217
|
+
if got != "0.4.15" {
|
|
218
|
+
t.Fatalf("版本 = %q,期望 0.4.15", got)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 读不到版本就留空 —— 绝不编一个看起来对的版本号。
|
|
223
|
+
func TestKnowledgeVersionEmptyWhenUnreadable(t *testing.T) {
|
|
224
|
+
home, _ := fakeKnowledgeEnv(t)
|
|
225
|
+
if got := readOpenVikingVersion("", home); got != "" {
|
|
226
|
+
t.Fatalf("没有命令时版本应为空串,实际 %q", got)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ── 版本比较 ── */
|
|
231
|
+
|
|
232
|
+
func TestKnowledgeVersionCompare(t *testing.T) {
|
|
233
|
+
cases := []struct {
|
|
234
|
+
cur, latest string
|
|
235
|
+
less bool
|
|
236
|
+
}{
|
|
237
|
+
{"0.4.15", "0.5.2", true},
|
|
238
|
+
{"0.5.2", "0.5.2", false},
|
|
239
|
+
{"0.6.0", "0.5.2", false},
|
|
240
|
+
{"0.5.2", "0.5.10", true},
|
|
241
|
+
{"1.0.0", "0.9.9", false},
|
|
242
|
+
{"", "0.5.2", false},
|
|
243
|
+
{"0.5.2", "", false},
|
|
244
|
+
}
|
|
245
|
+
for _, c := range cases {
|
|
246
|
+
if got := ovVersionLess(c.cur, c.latest); got != c.less {
|
|
247
|
+
t.Fatalf("ovVersionLess(%q, %q) = %v,期望 %v", c.cur, c.latest, got, c.less)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 预发布版**不**提示升级 —— 那不是「有新版」,是「有测试版」。
|
|
253
|
+
//
|
|
254
|
+
// ⚠️ 判据不是「有没有 '-'」:PEP 440 的预发布形态**不一定用连字符分隔**
|
|
255
|
+
// (实测本机 `ov --version` 吐 `0.4.16.dev0`,PyPI 上还有 `0.6.0rc1` 这种连字符都没有的)。
|
|
256
|
+
func TestKnowledgePreReleaseNotOffered(t *testing.T) {
|
|
257
|
+
for _, v := range []string{
|
|
258
|
+
"0.6.0rc1", "0.6.0-rc1", "1.0.0a2", "1.0.0-alpha2", "0.7.0.dev3",
|
|
259
|
+
"0.4.16.dev0", "1.0.0b1", "1.0.0pre1",
|
|
260
|
+
} {
|
|
261
|
+
if !ovIsPreRelease(v) {
|
|
262
|
+
t.Fatalf("%q 应判为预发布", v)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for _, v := range []string{"0.5.2", "1.0.0", "0.4.16", "2.0.0+local"} {
|
|
266
|
+
if ovIsPreRelease(v) {
|
|
267
|
+
t.Fatalf("%q 不应判为预发布", v)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 版本号提取要连后缀一起拿:`ov --version` 吐的是 `openviking 0.4.16.dev0`,
|
|
273
|
+
// 只取数字段会把这个 dev 版说成正式版 0.4.16(然后界面就误判「已是最新」)。
|
|
274
|
+
func TestKnowledgeVersionTokenKeepsSuffix(t *testing.T) {
|
|
275
|
+
cases := []struct{ in, want string }{
|
|
276
|
+
{"openviking 0.4.16.dev0", "0.4.16.dev0"},
|
|
277
|
+
{"OpenViking v0.5.2", "0.5.2"},
|
|
278
|
+
{"0.6.0rc1", "0.6.0rc1"},
|
|
279
|
+
{"0.5.2+local", "0.5.2+local"},
|
|
280
|
+
{"no version here", ""},
|
|
281
|
+
}
|
|
282
|
+
for _, c := range cases {
|
|
283
|
+
if got := ovVersionToken(c.in); got != c.want {
|
|
284
|
+
t.Fatalf("ovVersionToken(%q) = %q,期望 %q", c.in, got, c.want)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* ── 最新版本:查不到不报错 ── */
|
|
290
|
+
|
|
291
|
+
func TestKnowledgeLatestVersionUnavailable(t *testing.T) {
|
|
292
|
+
fakeKnowledgeEnv(t)
|
|
293
|
+
pypiLatest = func(pkg string) (string, error) { return "", os.ErrNotExist }
|
|
294
|
+
s := knowledgeService()
|
|
295
|
+
v, ok := s.ovLatestVersion(ovPyPackage)
|
|
296
|
+
if ok {
|
|
297
|
+
t.Fatalf("查不到时 ok 应为 false,实际 %q", v)
|
|
298
|
+
}
|
|
299
|
+
if v != "" {
|
|
300
|
+
t.Fatalf("查不到时版本应为空串,实际 %q", v)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 缓存:连续两次查询只打一次外网。
|
|
305
|
+
func TestKnowledgeLatestVersionCached(t *testing.T) {
|
|
306
|
+
fakeKnowledgeEnv(t)
|
|
307
|
+
calls := 0
|
|
308
|
+
pypiLatest = func(pkg string) (string, error) {
|
|
309
|
+
calls++
|
|
310
|
+
return "0.5.2", nil
|
|
311
|
+
}
|
|
312
|
+
s := knowledgeService()
|
|
313
|
+
for i := 0; i < 3; i++ {
|
|
314
|
+
v, ok := s.ovLatestVersion(ovPyPackage)
|
|
315
|
+
if !ok || v != "0.5.2" {
|
|
316
|
+
t.Fatalf("第 %d 次查询 = (%q, %v)", i+1, v, ok)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if calls != 1 {
|
|
320
|
+
t.Fatalf("外网查询次数 = %d,期望 1(要有缓存)", calls)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/* ── 可升级判定 ── */
|
|
325
|
+
|
|
326
|
+
func TestKnowledgeUpgradeAvailable(t *testing.T) {
|
|
327
|
+
home, binDir := fakeKnowledgeEnv(t)
|
|
328
|
+
// 造一个「装着 0.4.15」的 venv:dist-info 目录名就是版本来源
|
|
329
|
+
venv := filepath.Join(home, ovVenvName)
|
|
330
|
+
site := filepath.Join(venv, "Lib", "site-packages")
|
|
331
|
+
if runtime.GOOS != "windows" {
|
|
332
|
+
site = filepath.Join(venv, "lib", "python3.12", "site-packages")
|
|
333
|
+
}
|
|
334
|
+
if err := os.MkdirAll(filepath.Join(site, "openviking-0.4.15.dist-info"), 0o755); err != nil {
|
|
335
|
+
t.Fatal(err)
|
|
336
|
+
}
|
|
337
|
+
bin := filepath.Join(ovVenvBinDir(venv), "ov")
|
|
338
|
+
if runtime.GOOS == "windows" {
|
|
339
|
+
bin += ".exe"
|
|
340
|
+
}
|
|
341
|
+
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
|
|
342
|
+
t.Fatal(err)
|
|
343
|
+
}
|
|
344
|
+
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
345
|
+
t.Fatal(err)
|
|
346
|
+
}
|
|
347
|
+
writeFakeOV(t, binDir, "other", "#!/bin/sh\n") // PATH 上有个无关命令,别被认成 ov
|
|
348
|
+
|
|
349
|
+
pypiLatest = func(pkg string) (string, error) { return "0.5.2", nil }
|
|
350
|
+
s := knowledgeService()
|
|
351
|
+
info := s.knowledgeInfoOf(knowledgeSpecs()[0])
|
|
352
|
+
if !info.Installed {
|
|
353
|
+
t.Fatal("venv 里有命令时 Installed 应为 true")
|
|
354
|
+
}
|
|
355
|
+
if info.Version != "0.4.15" {
|
|
356
|
+
t.Fatalf("版本 = %q,期望 0.4.15", info.Version)
|
|
357
|
+
}
|
|
358
|
+
if !info.UpgradeAvailable {
|
|
359
|
+
t.Fatal("本机 0.4.15 < 最新 0.5.2,UpgradeAvailable 应为 true")
|
|
360
|
+
}
|
|
361
|
+
if info.LatestVersion != "0.5.2" {
|
|
362
|
+
t.Fatalf("最新版本 = %q,期望 0.5.2", info.LatestVersion)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* ── 安装 / 升级 ── */
|
|
367
|
+
|
|
368
|
+
// 安装动作:只跑服务端构造好的 argv,输出按行流回。
|
|
369
|
+
func TestKnowledgeInstallStreams(t *testing.T) {
|
|
370
|
+
if runtime.GOOS == "windows" {
|
|
371
|
+
t.Skip("本用例用 shell 脚本桩,Windows 上跑不了")
|
|
372
|
+
}
|
|
373
|
+
home, binDir := fakeKnowledgeEnv(t)
|
|
374
|
+
// 桩 python:建 venv 那步把「假 ov」放进 venv 的 bin 目录 ——
|
|
375
|
+
// 于是「跑完重新探测」这条链真的会翻成 installed=true,而不是靠测试自己断言。
|
|
376
|
+
venv := filepath.Join(home, ovVenvName)
|
|
377
|
+
py := filepath.Join(binDir, "python3")
|
|
378
|
+
script := "#!/bin/sh\necho \"python $*\"\n" +
|
|
379
|
+
"if [ \"$2\" = \"venv\" ]; then\n" +
|
|
380
|
+
" mkdir -p \"" + filepath.Join(venv, "bin") + "\"\n" +
|
|
381
|
+
" printf '#!/bin/sh\\necho \"ov 0.5.2\"\\n' > \"" + filepath.Join(venv, "bin", "ov") + "\"\n" +
|
|
382
|
+
" chmod +x \"" + filepath.Join(venv, "bin", "ov") + "\"\n" +
|
|
383
|
+
"fi\n" +
|
|
384
|
+
"exit 0\n"
|
|
385
|
+
if err := os.WriteFile(py, []byte(script), 0o755); err != nil {
|
|
386
|
+
t.Fatal(err)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
rec := httptest.NewRecorder()
|
|
390
|
+
knowledgeService().handleKnowledgeAction(rec,
|
|
391
|
+
httptest.NewRequest(http.MethodPost, "/desk/knowledge/openviking/install", nil))
|
|
392
|
+
if rec.Code != http.StatusOK {
|
|
393
|
+
t.Fatalf("状态码 = %d,期望 200(SSE):%s", rec.Code, rec.Body.String())
|
|
394
|
+
}
|
|
395
|
+
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
|
396
|
+
t.Fatalf("Content-Type = %q,期望 SSE", ct)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
events := map[string][]string{}
|
|
400
|
+
var cur string
|
|
401
|
+
sc := bufio.NewScanner(strings.NewReader(rec.Body.String()))
|
|
402
|
+
for sc.Scan() {
|
|
403
|
+
line := sc.Text()
|
|
404
|
+
switch {
|
|
405
|
+
case strings.HasPrefix(line, "event: "):
|
|
406
|
+
cur = strings.TrimPrefix(line, "event: ")
|
|
407
|
+
case strings.HasPrefix(line, "data: "):
|
|
408
|
+
events[cur] = append(events[cur], strings.TrimPrefix(line, "data: "))
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if len(events["open"]) != 1 {
|
|
412
|
+
t.Fatalf("应先发一条 open(告诉客户端要跑什么),实际 %v", events)
|
|
413
|
+
}
|
|
414
|
+
var open struct {
|
|
415
|
+
Commands []string `json:"commands"`
|
|
416
|
+
}
|
|
417
|
+
_ = json.Unmarshal([]byte(events["open"][0]), &open)
|
|
418
|
+
if len(open.Commands) != 3 {
|
|
419
|
+
t.Fatalf("open.commands = %v,期望三条(建 venv / pip install / pip show)", open.Commands)
|
|
420
|
+
}
|
|
421
|
+
if !strings.Contains(strings.Join(open.Commands, " "), "pip") {
|
|
422
|
+
t.Fatalf("命令里应有 pip:%v", open.Commands)
|
|
423
|
+
}
|
|
424
|
+
if !strings.Contains(strings.Join(events["line"], "\n"), "python") {
|
|
425
|
+
t.Fatalf("安装输出没有流回客户端:%v", events["line"])
|
|
426
|
+
}
|
|
427
|
+
if len(events["end"]) != 1 {
|
|
428
|
+
t.Fatalf("必须恰好一条 end 收尾,实际 %d", len(events["end"]))
|
|
429
|
+
}
|
|
430
|
+
var end struct {
|
|
431
|
+
OK bool `json:"ok"`
|
|
432
|
+
Knowledge knowledgeInfo `json:"knowledge"`
|
|
433
|
+
}
|
|
434
|
+
_ = json.Unmarshal([]byte(events["end"][0]), &end)
|
|
435
|
+
if !end.OK {
|
|
436
|
+
t.Fatalf("安装成功却回了 ok=false:%s", events["end"][0])
|
|
437
|
+
}
|
|
438
|
+
if !end.Knowledge.Installed {
|
|
439
|
+
t.Fatalf("跑完应重新探测到已安装(end.knowledge.installed 仍为 false):%s", events["end"][0])
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 未知 id / 未知动作一律 404,且**没有任何命令被执行** —— 这条接口不能变成任意命令执行器。
|
|
444
|
+
func TestKnowledgeActionRejectsUnknown(t *testing.T) {
|
|
445
|
+
fakeKnowledgeEnv(t)
|
|
446
|
+
for _, target := range []string{
|
|
447
|
+
"/desk/knowledge/rm-rf/install",
|
|
448
|
+
"/desk/knowledge/openviking/run",
|
|
449
|
+
"/desk/knowledge/openviking",
|
|
450
|
+
} {
|
|
451
|
+
rec := httptest.NewRecorder()
|
|
452
|
+
knowledgeService().handleKnowledgeAction(rec, httptest.NewRequest(http.MethodPost, target, nil))
|
|
453
|
+
if rec.Code != http.StatusNotFound {
|
|
454
|
+
t.Fatalf("%s 状态码 = %d,期望 404", target, rec.Code)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 失败的安装:退出码 + 错误要回给客户端,end.ok=false(不能静默当成功)。
|
|
460
|
+
func TestKnowledgeInstallFailureReported(t *testing.T) {
|
|
461
|
+
if runtime.GOOS == "windows" {
|
|
462
|
+
t.Skip("本用例用 shell 脚本桩,Windows 上跑不了")
|
|
463
|
+
}
|
|
464
|
+
_, binDir := fakeKnowledgeEnv(t)
|
|
465
|
+
py := filepath.Join(binDir, "python3")
|
|
466
|
+
if err := os.WriteFile(py, []byte("#!/bin/sh\necho 'EACCES: permission denied' >&2\nexit 3\n"), 0o755); err != nil {
|
|
467
|
+
t.Fatal(err)
|
|
468
|
+
}
|
|
469
|
+
rec := httptest.NewRecorder()
|
|
470
|
+
knowledgeService().handleKnowledgeAction(rec,
|
|
471
|
+
httptest.NewRequest(http.MethodPost, "/desk/knowledge/openviking/install", nil))
|
|
472
|
+
body := rec.Body.String()
|
|
473
|
+
if !strings.Contains(body, "EACCES: permission denied") {
|
|
474
|
+
t.Fatalf("stderr 没有流回客户端:%s", body)
|
|
475
|
+
}
|
|
476
|
+
if !strings.Contains(body, `"ok":false`) {
|
|
477
|
+
t.Fatalf("失败应以 ok=false 收尾:%s", body)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/* ── python 探测 ── */
|
|
482
|
+
|
|
483
|
+
// 找不到 ≥3.10 的 python → 就没有安装命令(界面要如实说「装不了」,而不是给一条跑不通的命令)。
|
|
484
|
+
func TestKnowledgeNoPythonNoSteps(t *testing.T) {
|
|
485
|
+
fakeKnowledgeEnv(t)
|
|
486
|
+
ovPythonProbe = func() ([]string, string) { return nil, "" }
|
|
487
|
+
isteps, usteps := ovSteps()
|
|
488
|
+
if len(isteps) != 0 || len(usteps) != 0 {
|
|
489
|
+
t.Fatalf("没有 python 时不该有步骤,实际 %v / %v", isteps, usteps)
|
|
490
|
+
}
|
|
491
|
+
rec := httptest.NewRecorder()
|
|
492
|
+
knowledgeService().handleKnowledgeAction(rec,
|
|
493
|
+
httptest.NewRequest(http.MethodPost, "/desk/knowledge/openviking/install", nil))
|
|
494
|
+
if rec.Code != http.StatusBadRequest {
|
|
495
|
+
t.Fatalf("状态码 = %d,期望 400(没有 python 就装不了)", rec.Code)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 3.9 不满足 OpenViking 的要求(需要 ≥3.10)。
|
|
500
|
+
func TestKnowledgePythonVersionGate(t *testing.T) {
|
|
501
|
+
if ovPythonVersionOK("3.9.7") {
|
|
502
|
+
t.Fatal("3.9.7 不该通过(OpenViking 需要 ≥3.10)")
|
|
503
|
+
}
|
|
504
|
+
if !ovPythonVersionOK("3.10.0") {
|
|
505
|
+
t.Fatal("3.10.0 应通过")
|
|
506
|
+
}
|
|
507
|
+
if !ovPythonVersionOK("3.13.3") {
|
|
508
|
+
t.Fatal("3.13.3 应通过")
|
|
509
|
+
}
|
|
510
|
+
}
|
package/internal/client/lan.go
CHANGED
|
@@ -8,9 +8,9 @@ import (
|
|
|
8
8
|
|
|
9
9
|
/* 局域网接入(P4 的第一级:让手机能连上)。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 默认就绑局域网(0.0.0.0:9100,手机开箱可连):这一开,**令牌就是唯一的门锁** ——
|
|
12
|
+
* 所以这里的两条守卫是配套的:局域网模式必须给足强度的令牌,短令牌直接拒绝启动;
|
|
13
|
+
* 只想本机访问用 `--local` 绑回 127.0.0.1。
|
|
14
14
|
*
|
|
15
15
|
* 三级接入里这一级是 L1(局域网);L2 的跨网中继需要服务端,不在本文件范围。
|
|
16
16
|
*/
|
|
@@ -31,7 +31,8 @@ import (
|
|
|
31
31
|
type pluginStep struct {
|
|
32
32
|
Args []string
|
|
33
33
|
Optional bool
|
|
34
|
-
SkipNote string
|
|
34
|
+
SkipNote string // Optional 步骤跳过时展示的原因
|
|
35
|
+
Env []string // 非空 = 用它替代继承的父进程环境(见 npmEnv)
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
func (p pluginStep) Command() string { return strings.Join(p.Args, " ") }
|
|
@@ -342,6 +343,9 @@ func runPluginStep(ctx context.Context, st pluginStep, onLine func(string)) (int
|
|
|
342
343
|
return 0, errors.New("空命令")
|
|
343
344
|
}
|
|
344
345
|
cmd := exec.CommandContext(ctx, st.Args[0], st.Args[1:]...)
|
|
346
|
+
if len(st.Env) > 0 {
|
|
347
|
+
cmd.Env = st.Env
|
|
348
|
+
}
|
|
345
349
|
pr, pw, err := os.Pipe()
|
|
346
350
|
if err != nil {
|
|
347
351
|
return 1, err
|
package/internal/client/prefs.go
CHANGED
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"net/http"
|
|
7
7
|
"os"
|
|
8
8
|
"path/filepath"
|
|
9
|
+
"strings"
|
|
9
10
|
)
|
|
10
11
|
|
|
11
12
|
/* 移动端「环境配置」的远端存储(2026-10-22 用户:
|
|
@@ -97,3 +98,69 @@ func (s *Service) handlePrefs(w http.ResponseWriter, r *http.Request) {
|
|
|
97
98
|
writeErr(w, http.StatusMethodNotAllowed, "只支持 GET / PUT")
|
|
98
99
|
}
|
|
99
100
|
}
|
|
101
|
+
|
|
102
|
+
// defaultEngineModel 读「我的 → 默认引擎/模型」(2026-10-26 用户:
|
|
103
|
+
// 「手动自动都要默认设置的引擎」)。
|
|
104
|
+
//
|
|
105
|
+
// 手动「整理」是从请求体拿的(移动端每次点都带 engine/model);后台自动归纳没有
|
|
106
|
+
// 请求体,只能自己去读 —— 与移动端同一份权威存储:/desk/prefs 落的
|
|
107
|
+
// ~/.magic-agent/mobile-prefs.json(键名由移动端定义,这里只按约定取两个)。
|
|
108
|
+
//
|
|
109
|
+
// 不做任何猜测或回退:配置里没设引擎就是空串(core 走自己的默认 llm),
|
|
110
|
+
// **不**用「备用引擎」替补 —— 备用是主引擎失败时才切的东西,不是默认值。
|
|
111
|
+
// 模型只在引擎非空时才返回(core 侧 model 不能脱离 engine 单独指定)。
|
|
112
|
+
func (s *Service) defaultEngineModel() (string, string) {
|
|
113
|
+
p := mobilePrefsPath()
|
|
114
|
+
if p == "" {
|
|
115
|
+
return "", ""
|
|
116
|
+
}
|
|
117
|
+
b, err := os.ReadFile(p)
|
|
118
|
+
if err != nil || len(b) == 0 {
|
|
119
|
+
return "", ""
|
|
120
|
+
}
|
|
121
|
+
var v map[string]any
|
|
122
|
+
if err := json.Unmarshal(b, &v); err != nil {
|
|
123
|
+
return "", ""
|
|
124
|
+
}
|
|
125
|
+
engine := strings.TrimSpace(prefString(v[prefKeyEngine]))
|
|
126
|
+
if engine == "" {
|
|
127
|
+
return "", ""
|
|
128
|
+
}
|
|
129
|
+
// 白名单同样要过:白名单外的引擎 core 会拒绝,自动归纳每轮都失败等于没跑。
|
|
130
|
+
if !s.opts.Profile.AllowsEngine(engine) {
|
|
131
|
+
s.logf("需求自动归纳: 默认引擎 %q 不在 profile 白名单里,回落 core 默认", engine)
|
|
132
|
+
return "", ""
|
|
133
|
+
}
|
|
134
|
+
return engine, strings.TrimSpace(prefString(v[prefKeyModel]))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 移动端 /desk/prefs 里的键名(客户端定义、服务端不解释;这里只读这两个)。
|
|
138
|
+
const (
|
|
139
|
+
prefKeyEngine = "magic.engine"
|
|
140
|
+
prefKeyModel = "magic.model"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// prefString 把配置值规整成字符串(值理论上都是字符串,但坏文件兜一下)。
|
|
144
|
+
func prefString(v any) string {
|
|
145
|
+
if s, ok := v.(string); ok {
|
|
146
|
+
return s
|
|
147
|
+
}
|
|
148
|
+
return ""
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// logf 防御性写日志:Options.Logger 可空(单测 / 内嵌调用),nil 时**不能 panic** ——
|
|
152
|
+
// 自动归纳是后台弱路径,日志丢了可以接受,把客户端带崩不行。
|
|
153
|
+
func (s *Service) logf(format string, args ...any) {
|
|
154
|
+
if s.log == nil {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
s.log.Printf(format, args...)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// modelSuffix 日志里拼「 · 模型」;没配模型时只留引擎名。
|
|
161
|
+
func modelSuffix(model string) string {
|
|
162
|
+
if model == "" {
|
|
163
|
+
return ""
|
|
164
|
+
}
|
|
165
|
+
return " · " + model
|
|
166
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"strconv"
|
|
6
|
+
"strings"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// init 在 Linux 上把 procStart 换成精确的版本:/proc/self/stat 的字段 22(starttime,单位
|
|
11
|
+
// 是开机后的时钟滴答)+ btime(/proc/stat),算出本进程真正被拉起的那一刻。
|
|
12
|
+
// 只关系到「盘上的 magic-agent 是不是本进程启动后才被 npm 换掉的」(见 replacedAfterStart),
|
|
13
|
+
// 读不到就保留 mtime 兜底,不影响任何正常路径。
|
|
14
|
+
func init() {
|
|
15
|
+
start, ok := linuxProcessStart()
|
|
16
|
+
if !ok {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
procStart = func() (time.Time, bool) { return start, true }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func linuxProcessStart() (time.Time, bool) {
|
|
23
|
+
b, err := os.ReadFile("/proc/self/stat")
|
|
24
|
+
if err != nil {
|
|
25
|
+
return time.Time{}, false
|
|
26
|
+
}
|
|
27
|
+
// comm 可能带空格和括号:从最后一个 ')' 之后才开始数字段。之后第一个字段是 3(state),
|
|
28
|
+
// starttime 是第 22 个 ⇒ 下标 22-3 = 19。
|
|
29
|
+
s := string(b)
|
|
30
|
+
i := strings.LastIndexByte(s, ')')
|
|
31
|
+
if i < 0 {
|
|
32
|
+
return time.Time{}, false
|
|
33
|
+
}
|
|
34
|
+
fields := strings.Fields(s[i+1:])
|
|
35
|
+
if len(fields) < 20 {
|
|
36
|
+
return time.Time{}, false
|
|
37
|
+
}
|
|
38
|
+
ticks, err := strconv.ParseUint(fields[19], 10, 64)
|
|
39
|
+
if err != nil {
|
|
40
|
+
return time.Time{}, false
|
|
41
|
+
}
|
|
42
|
+
sb, err := os.ReadFile("/proc/stat")
|
|
43
|
+
if err != nil {
|
|
44
|
+
return time.Time{}, false
|
|
45
|
+
}
|
|
46
|
+
var btime uint64
|
|
47
|
+
for _, ln := range strings.Split(string(sb), "\n") {
|
|
48
|
+
if strings.HasPrefix(ln, "btime ") {
|
|
49
|
+
btime, _ = strconv.ParseUint(strings.TrimSpace(ln[len("btime "):]), 10, 64)
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if btime == 0 {
|
|
54
|
+
return time.Time{}, false
|
|
55
|
+
}
|
|
56
|
+
// USER_HZ 在 Linux 上恒为 100(内核导出给用户态的时钟刻度,不随 CONFIG_HZ 变)。
|
|
57
|
+
sec := btime + ticks/100
|
|
58
|
+
return time.Unix(int64(sec), int64(ticks%100)*10_000_000), true
|
|
59
|
+
}
|