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.
Files changed (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -0,0 +1,854 @@
1
+ package client
2
+
3
+ /* knowledge.go — 「发现 → 知识库」:OpenViking 的本机安装状态 + 一键安装 / 升级。
4
+ *
5
+ * OpenViking 是面向 AI Agent 的**上下文数据库**(viking:// 文件系统范式:记忆、资源、
6
+ * 技能统一组织,按需分层加载)。它**不是引擎** —— core 的 `--engines` 里没有它、也不该有:
7
+ * core 是「跑一轮 agent 就退出」的进程,没有「本机常驻一个知识库服务」这个概念。
8
+ * 所以这一层能力放在插件(magic-client)侧,与 plugins.go / scripts.go 同源。
9
+ *
10
+ * ── 纪律 ──
11
+ * 1) 命令**只来自下面这张表**(argv 数组,不过 shell)—— 接口不接受客户端传来的命令,
12
+ * 唯一的外部输入是 id 与 action,找不到就 404。否则这个接口等于一个任意命令执行器。
13
+ * 2) 不假装成功:探测不到就报 installed=false + 说明;安装失败把 stderr 原样流回去。
14
+ * 3) 版本号读不到就留空 —— 绝不编一个看起来对的版本号。
15
+ * 4) 不自动初始化服务:openviking-server init 需要 api_key,那一步留给人(note 里说清去哪配)。
16
+ *
17
+ * ── 三处「不确定」怎么处理(不猜)──
18
+ * · CLI 名字:0.5.2 的 PyPI 文档只确认 `ov`,而火山引擎 0.4.15 的文档用 `openviking`。
19
+ * → 两个名字**都**探测(ovCLINames),认错名字的后果是「明明装了却说没装」。
20
+ * · `ov --version` 是否被支持:未确认 → 版本读取分四层兜底(见 readOpenVikingVersion)。
21
+ * · 最新版本:走 PyPI JSON **运行期查**,代码里不写死版本号(今天 0.5.2,明天就旧了)。
22
+ */
23
+
24
+ import (
25
+ "bytes"
26
+ "context"
27
+ "encoding/json"
28
+ "fmt"
29
+ "io"
30
+ "net/http"
31
+ "net/url"
32
+ "os"
33
+ "os/exec"
34
+ "path/filepath"
35
+ "regexp"
36
+ "runtime"
37
+ "strconv"
38
+ "strings"
39
+ "time"
40
+ )
41
+
42
+ const (
43
+ // ovPyPackage OpenViking 的 PyPI 包名(pip install 用它)。
44
+ ovPyPackage = "openviking"
45
+ // ovVenvName 自建 venv 的目录名(家目录下)。隔离安装,不污染系统 Python
46
+ // (PEP 668 externally-managed 与系统 pip 权限那两坑都绕开)。
47
+ ovVenvName = ".openviking-venv"
48
+ // ovEnvBin 环境变量:显式指定 ov 命令位置(与 MAGIC_CLIENT_SHORTS_DIR 同一思路)。
49
+ ovEnvBin = "MAGIC_CLIENT_OPENVIKING_BIN"
50
+ // ovEnvPython 环境变量:显式指定用来建 venv 的 python。
51
+ ovEnvPython = "MAGIC_CLIENT_PYTHON"
52
+ )
53
+
54
+ // ovCLINames 命令名候选。0.5.2 的 PyPI 文档给的是 `ov`;火山引擎 0.4.15 的文档用 `openviking`。
55
+ // 两个都认 —— 认错的后果是「明明装了却说没装」。
56
+ var ovCLINames = []string{"ov", "openviking"}
57
+
58
+ /* ── 注入点(测试打桩用;生产走下面的默认实现)── */
59
+
60
+ // ovHomeDir 家目录。
61
+ var ovHomeDir = func() (string, error) { return os.UserHomeDir() }
62
+
63
+ // ovPythonProbe 找一个可用的 python(≥3.10)。返回 (命令前缀, 版本);找不到返回 (nil, "")。
64
+ // 前缀可能是多个词(Windows 的 `py -3`),所以是 []string 而不是字符串。
65
+ var ovPythonProbe = func() ([]string, string) { return findPython310() }
66
+
67
+ // pypiLatest 查 PyPI 上的最新版本。测试把它换成桩(不打外网)。
68
+ var pypiLatest = func(pkg string) (string, error) { return fetchPyPILatest(pkg) }
69
+
70
+ // knowledgeSpec 知识库目录里的一项(服务端固定表,不接受客户端输入)。
71
+ type knowledgeSpec struct {
72
+ ID string
73
+ Name string
74
+ CLI string
75
+ PyPIPackage string
76
+ Tagline string
77
+ Description string
78
+ Requirements string
79
+ Homepage string
80
+ Examples []string
81
+ // UsePrompt 是「让它干活」时预填到输入框里的话(用户补参数即可发送)。
82
+ UsePrompt string
83
+ }
84
+
85
+ func knowledgeSpecs() []knowledgeSpec {
86
+ return []knowledgeSpec{
87
+ {
88
+ ID: "openviking",
89
+ Name: "OpenViking",
90
+ CLI: "ov",
91
+ PyPIPackage: ovPyPackage,
92
+ Tagline: "Agent 的上下文数据库:记忆 · 知识 RAG · 技能,统一收进 viking://",
93
+ Description: "一个专门给 AI Agent 用的上下文数据库:把文档、网页、笔记这些非结构化资料丢进去," +
94
+ "它自动解析、切分、向量化、分层索引,Agent 需要时**按需取用**而不是整包塞进上下文窗口。" +
95
+ "资料用 viking:// 文件系统组织(目录式检索,检索范围能一层层收窄)," +
96
+ "会话结束后还能把要点沉淀成长期记忆。" +
97
+ "对 Agent 框架有两种接法:命令行(`ov`)或 MCP(`openviking-mcp`)。" +
98
+ "本机装好后还需在 ~/.openviking/ov.conf 里配 workspace 与 embedding / vlm 的 api_key 才会真的开始检索。",
99
+ Requirements: "Python ≥ 3.10(官方 wheel 为 cp310-abi3,Windows / Linux x64 / Linux arm64 / macOS 都有预编译版);需要能访问 PyPI",
100
+ Homepage: "https://github.com/volcengine/OpenViking",
101
+ Examples: []string{
102
+ "ov status # 看服务状态",
103
+ "ov add-resource https://github.com/volcengine/OpenViking # 收一份资料进去",
104
+ "ov find \"这个仓库的架构是怎样的\" # 语义检索",
105
+ "openviking-server init # 初始化本地服务(会问你要 api_key)",
106
+ },
107
+ UsePrompt: "用 OpenViking 查知识库。\n问题:",
108
+ },
109
+ }
110
+ }
111
+
112
+ func findKnowledge(id string) (knowledgeSpec, bool) {
113
+ id = strings.TrimSpace(strings.ToLower(id))
114
+ for _, k := range knowledgeSpecs() {
115
+ if k.ID == id {
116
+ return k, true
117
+ }
118
+ }
119
+ return knowledgeSpec{}, false
120
+ }
121
+
122
+ // knowledgeInfo GET /desk/knowledge 的一项(客户端只读,不做第二次推断)。
123
+ type knowledgeInfo struct {
124
+ ID string `json:"id"`
125
+ Name string `json:"name"`
126
+ CLI string `json:"cli"`
127
+ Tagline string `json:"tagline"`
128
+ Description string `json:"description"`
129
+ Requirements string `json:"requirements,omitempty"`
130
+ Homepage string `json:"homepage,omitempty"`
131
+ Installed bool `json:"installed"`
132
+ Version string `json:"version,omitempty"`
133
+ Bin string `json:"bin,omitempty"`
134
+ // BinSource 命令从哪来:env(环境变量指定)| venv(本项目自建 venv)| path(PATH 上)。
135
+ BinSource string `json:"binSource,omitempty"`
136
+ // LatestVersion PyPI 上的最新版本(查不到就空串 + note 说明,不报错卡住页面)。
137
+ LatestVersion string `json:"latestVersion,omitempty"`
138
+ // UpgradeAvailable 本机版本确实比最新版本旧。
139
+ UpgradeAvailable bool `json:"upgradeAvailable"`
140
+ // PythonPath / PythonVersion 探测到的、可以用来建 venv 的 python。
141
+ PythonPath string `json:"pythonPath,omitempty"`
142
+ PythonVersion string `json:"pythonVersion,omitempty"`
143
+ Install string `json:"install"`
144
+ Upgrade string `json:"upgrade"`
145
+ Examples []string `json:"examples"`
146
+ UsePrompt string `json:"usePrompt"`
147
+ // Note 讲清「为什么是现在这个状态」,客户端原样显示,不自己编。
148
+ Note string `json:"note,omitempty"`
149
+ }
150
+
151
+ /* ── 探测 ── */
152
+
153
+ // ovDetect 探测结果。
154
+ type ovDetect struct {
155
+ Bin string // 命令绝对路径(空 = 没找到)
156
+ Source string // env | venv | path
157
+ VenvRoot string // 自建 venv 的根目录
158
+ VenvPython string // venv 里的 python
159
+ }
160
+
161
+ // ovVenvRoot 自建 venv 的位置(家目录下)。
162
+ func ovVenvRoot() string {
163
+ home, err := ovHomeDir()
164
+ if err != nil || strings.TrimSpace(home) == "" {
165
+ return ""
166
+ }
167
+ return filepath.Join(home, ovVenvName)
168
+ }
169
+
170
+ // ovVenvBinDir venv 里放可执行文件的目录:Windows 是 Scripts,POSIX 是 bin。
171
+ func ovVenvBinDir(root string) string {
172
+ if root == "" {
173
+ return ""
174
+ }
175
+ if runtime.GOOS == "windows" {
176
+ return filepath.Join(root, "Scripts")
177
+ }
178
+ return filepath.Join(root, "bin")
179
+ }
180
+
181
+ // ovVenvPython venv 里的 python。
182
+ func ovVenvPython(root string) string {
183
+ if root == "" {
184
+ return ""
185
+ }
186
+ if runtime.GOOS == "windows" {
187
+ return filepath.Join(root, "Scripts", "python.exe")
188
+ }
189
+ return filepath.Join(root, "bin", "python")
190
+ }
191
+
192
+ // detectOpenViking 找本机的 OpenViking 命令。
193
+ //
194
+ // 候选顺序(每一步都有理由):
195
+ // 1. 环境变量 —— 用户显式指定的优先级最高(他可能装到了别处)。
196
+ // 2. 自建 venv —— **必须排在 PATH 之前**:一键安装装进 venv 后 `ov` 并不在 PATH 上,
197
+ // 只查 PATH 会把「刚装好」误报成「未安装」,那是本功能最容易出的错。
198
+ // 3. PATH —— 最后才是系统里已有的。
199
+ func detectOpenViking() ovDetect {
200
+ d := ovDetect{VenvRoot: ovVenvRoot()}
201
+
202
+ if p := strings.TrimSpace(os.Getenv(ovEnvBin)); p != "" {
203
+ if st, err := os.Stat(p); err == nil && !st.IsDir() {
204
+ d.Bin, d.Source = p, "env"
205
+ return d
206
+ }
207
+ }
208
+
209
+ if dir := ovVenvBinDir(d.VenvRoot); dir != "" {
210
+ for _, name := range ovCLINames {
211
+ p := filepath.Join(dir, name)
212
+ if runtime.GOOS == "windows" {
213
+ p += ".exe"
214
+ }
215
+ if st, err := os.Stat(p); err == nil && !st.IsDir() {
216
+ d.Bin, d.Source = p, "venv"
217
+ d.VenvPython = ovVenvPython(d.VenvRoot)
218
+ return d
219
+ }
220
+ }
221
+ }
222
+
223
+ for _, name := range ovCLINames {
224
+ if p, err := exec.LookPath(name); err == nil {
225
+ d.Bin, d.Source = p, "path"
226
+ return d
227
+ }
228
+ }
229
+ return d
230
+ }
231
+
232
+ /* ── 版本号(分层兜底,绝不编)── */
233
+
234
+ // ovVersionCoreRe 匹配 x.y.z 形态的数字段(版本号的「核心」)。
235
+ var ovVersionCoreRe = regexp.MustCompile(`\d+\.\d+\.\d+`)
236
+
237
+ // ovSuffixRe 版本号后面的修饰部分(`.dev0` / `-rc1` / `+local` …)。
238
+ var ovSuffixRe = regexp.MustCompile(`^[0-9A-Za-z.\-+]*`)
239
+
240
+ // ovSplitVersion 把一行输出里的版本号拆成 (核心, 后缀)。
241
+ //
242
+ // ⚠️ **只看第一行**:版本输出都在第一行,后面的行常是路径或别的程序的版本
243
+ // (实测 llm 引擎的 venv 坏掉时,输出里满是 `3.12.13` 这类路径里的数字)。
244
+ //
245
+ // ⚠️ 后缀必须留着:PEP 440 的预发布形态**不一定用 '-' 分隔** ——
246
+ // 实测本机 `ov --version` 吐的是 `openviking 0.4.16.dev0`,
247
+ // 而 PyPI 上也有 `0.6.0rc1` 这种形态。只看 '-' 会把 dev 版当成正式版。
248
+ func ovSplitVersion(line string) (core, suffix string, ok bool) {
249
+ m := ovVersionCoreRe.FindString(line)
250
+ if m == "" {
251
+ return "", "", false
252
+ }
253
+ rest := line[strings.Index(line, m)+len(m):]
254
+ return m, ovSuffixRe.FindString(rest), true
255
+ }
256
+
257
+ // ovFirstLine 取文本第一行。
258
+ func ovFirstLine(s string) string {
259
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
260
+ return s[:i]
261
+ }
262
+ return s
263
+ }
264
+
265
+ // ovVersionToken 从一行输出里取出版本号(含后缀,如 "0.4.16.dev0");没有则空串。
266
+ func ovVersionToken(line string) string {
267
+ core, suffix, ok := ovSplitVersion(line)
268
+ if !ok {
269
+ return ""
270
+ }
271
+ return core + suffix
272
+ }
273
+
274
+ // readOpenVikingVersion 尽力读出本机 OpenViking 的版本号;读不到就空串。
275
+ //
276
+ // 四层,逐层更"不依赖解释器":
277
+ //
278
+ // L1 <bin> --version CLI 自述,最权威;但 `ov --version` 是否被支持**未确认**
279
+ // (实测本机 0.4.16 是支持的,但不能拿别人的版本赌)→ 不能只靠这层。
280
+ // L2 dist-info 目录名 纯文件系统,不依赖解释器 —— 比 L3 可靠:解释器可能被搬走
281
+ // 或损坏(本仓 llm 引擎的 venv 就踩过这个坑)。
282
+ // L3 importlib.metadata 对任意安装器都准,但要求解释器能跑。
283
+ // L4 pip show 兜底,要求 pip 在。
284
+ func readOpenVikingVersion(bin, venvRoot string) string {
285
+ if v := versionFromCLI(bin); v != "" {
286
+ return v
287
+ }
288
+ if v := versionFromDistInfo(bin, venvRoot); v != "" {
289
+ return v
290
+ }
291
+ return versionFromInterpreter(venvRoot)
292
+ }
293
+
294
+ // versionFromCLI 跑一次 `--version`,取输出里的版本号。
295
+ func versionFromCLI(bin string) string {
296
+ if bin == "" {
297
+ return ""
298
+ }
299
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
300
+ defer cancel()
301
+ out, errOut, err := ovCapture(ctx, []string{bin, "--version"})
302
+ if err != nil && out == "" && errOut == "" {
303
+ return ""
304
+ }
305
+ if v := ovVersionToken(ovFirstLine(out)); v != "" {
306
+ return v
307
+ }
308
+ // 少数 CLI 把版本打到 stderr(stdout 只剩日志)→ 兜一次。
309
+ return ovVersionToken(ovFirstLine(errOut))
310
+ }
311
+
312
+ // versionFromDistInfo 从安装元数据目录名读版本(openviking-0.5.2.dist-info)。
313
+ func versionFromDistInfo(bin, venvRoot string) string {
314
+ for _, dir := range ovSitePackagesDirs(bin, venvRoot) {
315
+ ents, err := os.ReadDir(dir)
316
+ if err != nil {
317
+ continue
318
+ }
319
+ for _, e := range ents {
320
+ name := e.Name()
321
+ if !strings.HasPrefix(name, ovPyPackage+"-") || !strings.HasSuffix(name, ".dist-info") {
322
+ continue
323
+ }
324
+ v := strings.TrimSuffix(strings.TrimPrefix(name, ovPyPackage+"-"), ".dist-info")
325
+ if ovVersionCoreRe.MatchString(v) {
326
+ return v
327
+ }
328
+ }
329
+ }
330
+ return ""
331
+ }
332
+
333
+ // ovSitePackagesDirs 列出可能装着 openviking 的 site-packages 目录。
334
+ func ovSitePackagesDirs(bin, venvRoot string) []string {
335
+ var dirs []string
336
+ add := func(p string) {
337
+ if p == "" {
338
+ return
339
+ }
340
+ if st, err := os.Stat(p); err == nil && st.IsDir() {
341
+ dirs = append(dirs, p)
342
+ }
343
+ }
344
+ if venvRoot != "" {
345
+ // Windows 的 venv 布局:<root>\Lib\site-packages
346
+ add(filepath.Join(venvRoot, "Lib", "site-packages"))
347
+ // POSIX:<root>\lib\python3.x\site-packages
348
+ if gl, err := filepath.Glob(filepath.Join(venvRoot, "lib", "python*", "site-packages")); err == nil {
349
+ for _, p := range gl {
350
+ add(p)
351
+ }
352
+ }
353
+ }
354
+ if bin != "" {
355
+ dir := filepath.Dir(bin)
356
+ for i := 0; i < 4; i++ {
357
+ add(filepath.Join(dir, "site-packages"))
358
+ add(filepath.Join(dir, "Lib", "site-packages"))
359
+ parent := filepath.Dir(dir)
360
+ if parent == dir {
361
+ break
362
+ }
363
+ dir = parent
364
+ }
365
+ }
366
+ return dirs
367
+ }
368
+
369
+ // versionFromInterpreter 问解释器要版本(importlib.metadata 与 pip show 两条都试)。
370
+ func versionFromInterpreter(venvRoot string) string {
371
+ var pys [][]string
372
+ if p := ovVenvPython(venvRoot); p != "" {
373
+ pys = append(pys, []string{p})
374
+ }
375
+ if pref, _ := ovPythonProbe(); len(pref) > 0 {
376
+ pys = append(pys, pref)
377
+ }
378
+ for _, py := range pys {
379
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
380
+ meta := append(append([]string{}, py...),
381
+ "-c", "import importlib.metadata as m;print(m.version('"+ovPyPackage+"'))")
382
+ out, _, _ := ovCapture(ctx, meta)
383
+ cancel()
384
+ if v := ovVersionToken(strings.TrimSpace(out)); v != "" {
385
+ return v
386
+ }
387
+
388
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second)
389
+ show := append(append([]string{}, py...), "-m", "pip", "show", ovPyPackage)
390
+ out2, _, _ := ovCapture(ctx2, show)
391
+ cancel2()
392
+ for _, line := range strings.Split(out2, "\n") {
393
+ if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "version:") {
394
+ continue
395
+ }
396
+ if v := ovVersionToken(line); v != "" {
397
+ return v
398
+ }
399
+ }
400
+ }
401
+ return ""
402
+ }
403
+
404
+ // ovVersionParts 把版本号拆成三段数字;不是 x.y.z 就返回 false。
405
+ func ovVersionParts(v string) ([3]int, bool) {
406
+ m := ovVersionCoreRe.FindString(v)
407
+ if m == "" {
408
+ return [3]int{}, false
409
+ }
410
+ nums := regexp.MustCompile(`\d+`).FindAllString(m, 3)
411
+ var out [3]int
412
+ if len(nums) == 0 {
413
+ return out, false
414
+ }
415
+ for i := 0; i < len(nums) && i < 3; i++ {
416
+ n, err := strconv.Atoi(nums[i])
417
+ if err != nil {
418
+ return out, false
419
+ }
420
+ out[i] = n
421
+ }
422
+ return out, true
423
+ }
424
+
425
+ // ovVersionLess 报告 cur 是否比 latest 旧(只比数字段,后缀忽略)。
426
+ func ovVersionLess(cur, latest string) bool {
427
+ c, ok1 := ovVersionParts(cur)
428
+ l, ok2 := ovVersionParts(latest)
429
+ if !ok1 || !ok2 {
430
+ return false
431
+ }
432
+ for i := 0; i < 3; i++ {
433
+ if c[i] != l[i] {
434
+ return c[i] < l[i]
435
+ }
436
+ }
437
+ return false
438
+ }
439
+
440
+ // ovIsPreRelease 报告版本号是不是预发布。
441
+ //
442
+ // ⚠️ PEP 440 的预发布形态**不一定用 '-' 分隔**:实测本机 `ov --version` 吐的是
443
+ // `openviking 0.4.16.dev0`(用 '.'),而 PyPI 上还有 `0.6.0rc1` 这种**连字符都没有**的。
444
+ // 所以判据是「后缀里出现 dev / rc / alpha / beta / pre 就算预发布」,
445
+ // 而不是「有没有 '-'」—— 否则 dev 版会被当成正式版,界面就会说「已是最新」。
446
+ func ovIsPreRelease(v string) bool {
447
+ _, suffix, ok := ovSplitVersion(v)
448
+ if !ok {
449
+ return false
450
+ }
451
+ lower := strings.ToLower(strings.TrimLeft(suffix, "-._"))
452
+ if lower == "" {
453
+ return false
454
+ }
455
+ for _, p := range []string{"dev", "rc", "alpha", "beta", "pre"} {
456
+ if strings.HasPrefix(lower, p) {
457
+ return true
458
+ }
459
+ }
460
+ /* PEP 440 里 alpha / beta 可以只写一个字母("1.0.0a2" = "1.0.0alpha2")。
461
+ 要看字母后面是不是数字或结尾 —— 否则 "abc" 之类会被误判。 */
462
+ for _, p := range []string{"a", "b"} {
463
+ if !strings.HasPrefix(lower, p) {
464
+ continue
465
+ }
466
+ rest := lower[len(p):]
467
+ if rest == "" || (rest[0] >= '0' && rest[0] <= '9') {
468
+ return true
469
+ }
470
+ }
471
+ return false
472
+ }
473
+
474
+ /* ── python ── */
475
+
476
+ // findPython310 找一个能用的 python(≥3.10)。返回 (命令前缀, 版本)。
477
+ //
478
+ // 顺序:环境变量 → python → py -3 → python3。
479
+ // ⚠️ Windows 上 `python3` 是 Microsoft Store 的**占位桩**:跑它会弹商店、且没有输出。
480
+ //
481
+ // 所以它排在最后,而且必须靠「真能跑出版本号」来判活 —— 这也顺带挡掉了另一个坑:
482
+ // venv 的 `python` 指向的基解释器一旦被换掉/搬走,L1/L3 都会失灵(见 versionFromDistInfo)。
483
+ func findPython310() ([]string, string) {
484
+ var cands [][]string
485
+ if p := strings.TrimSpace(os.Getenv(ovEnvPython)); p != "" {
486
+ cands = append(cands, []string{p})
487
+ }
488
+ cands = append(cands, []string{"python"}, []string{"py", "-3"}, []string{"python3"})
489
+
490
+ for _, c := range cands {
491
+ if len(c) == 1 && ovIsStoreStub(c[0]) {
492
+ continue
493
+ }
494
+ ver, ok := probePython(c)
495
+ if !ok {
496
+ continue
497
+ }
498
+ if !ovPythonVersionOK(ver) {
499
+ continue
500
+ }
501
+ return c, ver
502
+ }
503
+ return nil, ""
504
+ }
505
+
506
+ // ovIsStoreStub 报告这个命令是不是 Windows 上的 Microsoft Store 占位桩
507
+ // (路径在 WindowsApps 下 —— 那是 Store 的转发器,不是真的解释器)。
508
+ func ovIsStoreStub(name string) bool {
509
+ p, err := exec.LookPath(name)
510
+ if err != nil {
511
+ return false
512
+ }
513
+ return strings.Contains(strings.ToLower(filepath.ToSlash(p)), "/windowsapps/")
514
+ }
515
+
516
+ // probePython 跑一次 `python -c "print(sys.version)"`,拿到三段版本号。
517
+ func probePython(argv []string) (string, bool) {
518
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
519
+ defer cancel()
520
+ full := append(append([]string{}, argv...),
521
+ "-c", "import sys;print('%d.%d.%d'%sys.version_info[:3])")
522
+ out, _, err := ovCapture(ctx, full)
523
+ if err != nil {
524
+ return "", false
525
+ }
526
+ v := strings.TrimSpace(strings.SplitN(out, "\n", 2)[0])
527
+ if !ovVersionCoreRe.MatchString(v) {
528
+ return "", false
529
+ }
530
+ return v, true
531
+ }
532
+
533
+ // ovPythonVersionOK 判断版本是否 ≥ 3.10(OpenViking 的硬要求)。
534
+ func ovPythonVersionOK(v string) bool {
535
+ parts := strings.Split(v, ".")
536
+ if len(parts) < 2 {
537
+ return false
538
+ }
539
+ maj, err1 := strconv.Atoi(parts[0])
540
+ min, err2 := strconv.Atoi(parts[1])
541
+ if err1 != nil || err2 != nil {
542
+ return false
543
+ }
544
+ return maj > 3 || (maj == 3 && min >= 10)
545
+ }
546
+
547
+ /* ── PyPI 最新版本 ── */
548
+
549
+ // fetchPyPILatest 查 PyPI 上某个包的最新版本。
550
+ func fetchPyPILatest(pkg string) (string, error) {
551
+ client := &http.Client{Timeout: 6 * time.Second}
552
+ u := "https://pypi.org/pypi/" + url.PathEscape(pkg) + "/json"
553
+ resp, err := client.Get(u)
554
+ if err != nil {
555
+ return "", err
556
+ }
557
+ defer resp.Body.Close()
558
+ if resp.StatusCode != http.StatusOK {
559
+ return "", fmt.Errorf("PyPI 返回 %d", resp.StatusCode)
560
+ }
561
+ var j struct {
562
+ Info struct {
563
+ Version string `json:"version"`
564
+ } `json:"info"`
565
+ }
566
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&j); err != nil {
567
+ return "", err
568
+ }
569
+ v := strings.TrimSpace(j.Info.Version)
570
+ if v == "" {
571
+ return "", fmt.Errorf("PyPI 没给版本号")
572
+ }
573
+ return v, nil
574
+ }
575
+
576
+ /* ── 服务端组装 ── */
577
+
578
+ // ovLatestVersion 取 PyPI 最新版本。
579
+ //
580
+ // 缓存策略:成功缓存 6h,失败缓存 15min(失败不该每次开页面都重试一次外网)。
581
+ // 返回 (最新版本, 是否真的拿到了)。拿不到时**不报错** —— 界面照常显示本机状态,
582
+ // 只是「最新版本」那一栏空着 + note 说明查不到。
583
+ func (s *Service) ovLatestVersion(pkg string) (string, bool) {
584
+ s.ovMu.Lock()
585
+ ttl := 6 * time.Hour
586
+ if !s.ovLatestOK {
587
+ ttl = 15 * time.Minute
588
+ }
589
+ fresh := !s.ovLatestAt.IsZero() && time.Since(s.ovLatestAt) < ttl
590
+ v, ok := s.ovLatest, s.ovLatestOK
591
+ s.ovMu.Unlock()
592
+ if fresh {
593
+ return v, ok
594
+ }
595
+
596
+ nv, err := pypiLatest(pkg)
597
+ s.ovMu.Lock()
598
+ s.ovLatestAt = time.Now()
599
+ if err == nil && nv != "" {
600
+ s.ovLatest, s.ovLatestOK = nv, true
601
+ } else {
602
+ s.ovLatest, s.ovLatestOK = "", false
603
+ }
604
+ v, ok = s.ovLatest, s.ovLatestOK
605
+ s.ovMu.Unlock()
606
+ return v, ok
607
+ }
608
+
609
+ // ovSteps 构造安装 / 升级步骤(argv 数组,不过 shell)。
610
+ //
611
+ // 三步:
612
+ //
613
+ // <py> -m venv <venv> 建隔离环境(已有就复用,不删)
614
+ // <venvPy> -m pip install [--upgrade] openviking
615
+ // <venvPy> -m pip show openviking 只为把版本打进日志(失败不致命)
616
+ //
617
+ // 为什么不用官方那条 `pip install openviking --upgrade --force-reinstall`:
618
+ // 它会把依赖一起重装,又慢又容易在旧依赖上翻车;升级只需要 `--upgrade`。
619
+ func ovSteps() (install, upgrade []pluginStep) {
620
+ py, _ := ovPythonProbe()
621
+ if len(py) == 0 {
622
+ return nil, nil
623
+ }
624
+ venvRoot := ovVenvRoot()
625
+ venvPy := ovVenvPython(venvRoot)
626
+
627
+ mk := func(extra ...string) []pluginStep {
628
+ mkVenv := append(append([]string{}, py...), "-m", "venv", venvRoot)
629
+ installArgv := append([]string{venvPy}, "-m", "pip",
630
+ "install", "--disable-pip-version-check")
631
+ installArgv = append(installArgv, extra...)
632
+ installArgv = append(installArgv, ovPyPackage)
633
+ showArgv := append([]string{venvPy}, "-m", "pip", "show", ovPyPackage)
634
+ return []pluginStep{
635
+ {Args: mkVenv},
636
+ {Args: installArgv},
637
+ {Args: showArgv, Optional: true, SkipNote: "pip show 没输出 —— 版本号改从安装元数据取"},
638
+ }
639
+ }
640
+ return mk(), mk("--upgrade")
641
+ }
642
+
643
+ // knowledgeInfoOf 组装一项知识库的完整状态(探测 → 填 note)。
644
+ func (s *Service) knowledgeInfoOf(spec knowledgeSpec) knowledgeInfo {
645
+ info := knowledgeInfo{
646
+ ID: spec.ID, Name: spec.Name, CLI: spec.CLI,
647
+ Tagline: spec.Tagline, Description: spec.Description,
648
+ Requirements: spec.Requirements, Homepage: spec.Homepage,
649
+ Examples: spec.Examples, UsePrompt: spec.UsePrompt,
650
+ }
651
+
652
+ d := detectOpenViking()
653
+ if d.Bin != "" {
654
+ info.Installed = true
655
+ info.Bin = d.Bin
656
+ info.BinSource = d.Source
657
+ info.Version = readOpenVikingVersion(d.Bin, d.VenvRoot)
658
+ }
659
+
660
+ if pref, ver := ovPythonProbe(); len(pref) > 0 {
661
+ info.PythonPath = strings.Join(pref, " ")
662
+ info.PythonVersion = ver
663
+ }
664
+
665
+ latest, latestKnown := s.ovLatestVersion(spec.PyPIPackage)
666
+ info.LatestVersion = latest
667
+ if latestKnown && !ovIsPreRelease(latest) && info.Version != "" {
668
+ info.UpgradeAvailable = ovVersionLess(info.Version, latest)
669
+ }
670
+
671
+ isteps, usteps := ovSteps()
672
+ info.Install = pluginCommands(isteps)
673
+ info.Upgrade = pluginCommands(usteps)
674
+ info.Note = ovNote(info, d, latest, latestKnown)
675
+ return info
676
+ }
677
+
678
+ // ovNote 说明「为什么是现在这个状态」—— 客户端原样显示,不自己编。
679
+ func ovNote(info knowledgeInfo, d ovDetect, latest string, latestKnown bool) string {
680
+ var b strings.Builder
681
+ switch {
682
+ case info.Installed && info.Version != "":
683
+ fmt.Fprintf(&b, "已安装 v%s(%s)", info.Version, ovSourceLabel(d.Source))
684
+ case info.Installed:
685
+ fmt.Fprintf(&b, "已安装(%s),但读不到版本号(该 CLI 可能没有 --version)", ovSourceLabel(d.Source))
686
+ default:
687
+ b.WriteString("未安装:PATH 上没有 ov,也没有 " + ovVenvRoot())
688
+ if info.PythonPath == "" {
689
+ b.WriteString(";还没找到 Python ≥ 3.10,装不了")
690
+ }
691
+ }
692
+ if info.UpgradeAvailable {
693
+ fmt.Fprintf(&b, ";有新版 v%s 可升级", latest)
694
+ } else if latestKnown && info.Version != "" && !ovIsPreRelease(latest) {
695
+ fmt.Fprintf(&b, ";已是最新(v%s)", latest)
696
+ } else if !latestKnown {
697
+ b.WriteString(";查不到最新版本(离线或 PyPI 不可达)")
698
+ }
699
+ if info.Installed && info.BinSource == "venv" {
700
+ b.WriteString("。装完还要配 ~/.openviking/ov.conf(workspace + embedding / vlm 的 api_key)才会真的开始检索")
701
+ }
702
+ return b.String()
703
+ }
704
+
705
+ // ovSourceLabel 把命令来源说成人话。
706
+ func ovSourceLabel(source string) string {
707
+ switch source {
708
+ case "env":
709
+ return "命令由环境变量指定"
710
+ case "venv":
711
+ return "命令在本项目的隔离环境里"
712
+ case "path":
713
+ return "命令在 PATH 上"
714
+ default:
715
+ return ""
716
+ }
717
+ }
718
+
719
+ /* ── 接口 ── */
720
+
721
+ // handleKnowledge GET /desk/knowledge:知识库目录 + 本机安装状态。
722
+ func (s *Service) handleKnowledge(w http.ResponseWriter, r *http.Request) {
723
+ if r.Method != http.MethodGet {
724
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
725
+ return
726
+ }
727
+ specs := knowledgeSpecs()
728
+ items := make([]knowledgeInfo, 0, len(specs))
729
+ for _, k := range specs {
730
+ items = append(items, s.knowledgeInfoOf(k))
731
+ }
732
+ writeJSON(w, http.StatusOK, map[string]any{"knowledge": items})
733
+ }
734
+
735
+ /* handleKnowledgeAction POST /desk/knowledge/{id}/{install|upgrade}
736
+ *
737
+ * 回 SSE:`event: open`(这次要跑哪几条命令)→ 每条命令一行 `event: step`,
738
+ * 输出逐行 `event: line` → `event: end`(ok / code / 刷新后的状态)。
739
+ * 用 SSE 而不是等一个 JSON:pip 装一个包几秒到几十秒,手机上必须看得见在动。 */
740
+ func (s *Service) handleKnowledgeAction(w http.ResponseWriter, r *http.Request) {
741
+ if r.Method != http.MethodPost {
742
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
743
+ return
744
+ }
745
+ rest := strings.TrimPrefix(r.URL.Path, "/desk/knowledge/")
746
+ parts := strings.Split(strings.Trim(rest, "/"), "/")
747
+ if len(parts) != 2 {
748
+ writeErr(w, http.StatusNotFound, "用法:POST /desk/knowledge/{id}/{install|upgrade}")
749
+ return
750
+ }
751
+ spec, ok := findKnowledge(parts[0])
752
+ if !ok {
753
+ writeErr(w, http.StatusNotFound, "没有这个知识库:"+parts[0])
754
+ return
755
+ }
756
+ isteps, usteps := ovSteps()
757
+ var steps []pluginStep
758
+ switch parts[1] {
759
+ case "install":
760
+ steps = isteps
761
+ case "upgrade":
762
+ steps = usteps
763
+ default:
764
+ writeErr(w, http.StatusNotFound, "动作只有 install / upgrade")
765
+ return
766
+ }
767
+ if len(steps) == 0 {
768
+ writeErr(w, http.StatusBadRequest, "没找到可用的 Python(需要 ≥ 3.10),装不了")
769
+ return
770
+ }
771
+
772
+ // 并发保护:两个客户端同时点安装会抢同一个 venv。
773
+ s.ovMu.Lock()
774
+ if s.ovBusy {
775
+ s.ovMu.Unlock()
776
+ writeErr(w, http.StatusConflict, "已有安装 / 升级在进行,请等它结束")
777
+ return
778
+ }
779
+ s.ovBusy = true
780
+ s.ovMu.Unlock()
781
+ defer func() {
782
+ s.ovMu.Lock()
783
+ s.ovBusy = false
784
+ s.ovMu.Unlock()
785
+ }()
786
+
787
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
788
+ w.Header().Set("Cache-Control", "no-cache")
789
+ w.Header().Set("X-Accel-Buffering", "no")
790
+ flusher, _ := w.(http.Flusher)
791
+ send := func(event string, payload any) {
792
+ b, _ := json.Marshal(payload)
793
+ if event != "" {
794
+ fmt.Fprintf(w, "event: %s\n", event)
795
+ }
796
+ fmt.Fprintf(w, "data: %s\n\n", b)
797
+ if flusher != nil {
798
+ flusher.Flush()
799
+ }
800
+ }
801
+
802
+ cmds := make([]string, 0, len(steps))
803
+ for _, st := range steps {
804
+ cmds = append(cmds, st.Command())
805
+ }
806
+ send("open", map[string]any{"id": spec.ID, "action": parts[1], "commands": cmds})
807
+
808
+ /* 与 /desk/ask 同一口径:客户端断连不是「取消安装」。
809
+ 装到一半被手机锁屏杀掉会更糟(半个包装在盘上),所以跑完再说。 */
810
+ ctx := context.WithoutCancel(r.Context())
811
+ failed := ""
812
+ failCode := 0
813
+ for i, st := range steps {
814
+ send("step", map[string]any{"index": i, "command": st.Command()})
815
+ code, err := runPluginStep(ctx, st, func(line string) {
816
+ send("line", map[string]any{"index": i, "line": line})
817
+ })
818
+ if err != nil || code != 0 {
819
+ if st.Optional {
820
+ send("line", map[string]any{"index": i, "line": "↷ " + st.SkipNote})
821
+ continue
822
+ }
823
+ if err != nil {
824
+ failed = err.Error()
825
+ } else {
826
+ failed = fmt.Sprintf("%s 退出码 %d", st.Command(), code)
827
+ }
828
+ failCode = code
829
+ break
830
+ }
831
+ }
832
+
833
+ info := s.knowledgeInfoOf(spec) // 跑完重新探一次:成功与否都让界面拿到最新状态
834
+ if failed != "" {
835
+ send("end", map[string]any{"ok": false, "code": failCode, "error": failed, "knowledge": info})
836
+ return
837
+ }
838
+ send("end", map[string]any{"ok": true, "code": 0, "knowledge": info})
839
+ }
840
+
841
+ /* ── 小工具 ── */
842
+
843
+ // ovCapture 跑一条命令,分别拿到 stdout 与 stderr。
844
+ func ovCapture(ctx context.Context, argv []string) (string, string, error) {
845
+ if len(argv) == 0 {
846
+ return "", "", fmt.Errorf("空命令")
847
+ }
848
+ cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
849
+ var ob, eb bytes.Buffer
850
+ cmd.Stdout = &ob
851
+ cmd.Stderr = &eb
852
+ err := cmd.Run()
853
+ return ob.String(), eb.String(), err
854
+ }