magic-agent 1.0.1 → 1.0.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 (52) hide show
  1. package/CLIENT-CONTRACT.md +298 -0
  2. package/README.md +35 -0
  3. package/client-ui/assets/OFL-Caveat.txt +93 -0
  4. package/client-ui/assets/caveat.woff2 +0 -0
  5. package/client-ui/index.html +246 -0
  6. package/client-ui/pc/app.css +5110 -0
  7. package/client-ui/pc/assets/damiao-mascot.png +0 -0
  8. package/client-ui/pc/assets/icon.png +0 -0
  9. package/client-ui/pc/index.html +154 -0
  10. package/client-ui/pc/js/00-base.js +52 -0
  11. package/client-ui/pc/js/03-chat-message.js +880 -0
  12. package/client-ui/pc/js/04-task-list.js +430 -0
  13. package/client-ui/pc/js/05-task-list-render.js +812 -0
  14. package/client-ui/pc/js/06-ask-answer.js +204 -0
  15. package/client-ui/pc/js/07-chat-stream-render.js +493 -0
  16. package/client-ui/pc/js/08-ask-event-map.js +171 -0
  17. package/client-ui/pc/js/09-stream-apply.js +367 -0
  18. package/client-ui/pc/js/10-markdown.js +430 -0
  19. package/client-ui/pc/js/18-askbox.js +262 -0
  20. package/client-ui/pc/js/19-attachments.js +679 -0
  21. package/client-ui/pc/js/29-msg-variants.js +462 -0
  22. package/client-ui/pc/js/90-glue.js +212 -0
  23. package/client-ui/pc/js/91-workbench-stubs.js +134 -0
  24. package/client-ui/pc/js/92-composer.js +406 -0
  25. package/client-ui/pc/js/93-rail.js +362 -0
  26. package/client-ui/pc/vendor/highlight.min.js +1262 -0
  27. package/client-ui/pc/vendor/marked.min.js +6 -0
  28. package/client-ui/pc/vendor/purify.min.js +3 -0
  29. package/client-ui/shared/conversation.css +256 -0
  30. package/client-ui/shared/conversation.js +1225 -0
  31. package/client-ui/shared/transport-http.js +123 -0
  32. package/client-ui-mobile/assets/OFL-Caveat.txt +93 -0
  33. package/client-ui-mobile/assets/caveat.woff2 +0 -0
  34. package/client-ui-mobile/index.html +173 -0
  35. package/internal/agent/custom_models.go +151 -5
  36. package/internal/agent/custom_models_test.go +130 -7
  37. package/internal/agent/env.go +10 -2
  38. package/internal/agent/env_test.go +37 -0
  39. package/internal/agent/repair_models_test.go +194 -0
  40. package/internal/cli/ask.go +22 -18
  41. package/internal/cli/login.go +61 -4
  42. package/internal/cli/login_test.go +99 -0
  43. package/internal/cli/repair.go +60 -0
  44. package/internal/cli/repair_test.go +51 -0
  45. package/internal/cli/root.go +21 -0
  46. package/internal/cli/session.go +8 -1
  47. package/npm/dist/darwin-arm64/magic-agent +0 -0
  48. package/npm/dist/darwin-x64/magic-agent +0 -0
  49. package/npm/dist/linux-arm64/magic-agent +0 -0
  50. package/npm/dist/linux-x64/magic-agent +0 -0
  51. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  52. package/package.json +4 -1
@@ -0,0 +1,123 @@
1
+ /* 传输层的一个**现成实现**:HTTP(打 magic-client 插件的 /desk/* 接口)。
2
+ *
3
+ * ⚠️ 这是给「手上已经有一个 HTTP 端点」的对接方用的**可选件**,不是组件的一部分。
4
+ * 组件本身与传输无关(见 conversation.js 头部的 transport 契约)。
5
+ * 按定稿口径 magic-agent 自己不跑服务 —— 掌天瓶那种宿主应当自己实现一个走
6
+ * IPC → CLI 的 transport(引擎清单/MAGIC 的 ask 与 control/会话历史都由 CLI 承担),
7
+ * 而不是让组件去连某个服务。
8
+ *
9
+ * ── 这一份覆盖的接口(与 CLIENT-CONTRACT.md 第二章一致)──
10
+ * GET /desk/engines?models=1 引擎 + 模型 + 每引擎能力
11
+ * POST /desk/ask SSE:open / 事件 / end
12
+ * POST /desk/ask/{id}/control 打断 / 收工 / 回答案
13
+ * GET /desk/session/{id}/messages 全量历史
14
+ * GET /desk/session/{id}/events?after=<seq> 增量续读
15
+ *
16
+ * ── 用法 ──
17
+ * const transport = MagicTransportHttp({ base: '', token: '…' }); // 同源时 base 传空串
18
+ * MagicConversation.create({ el, transport, … });
19
+ */
20
+ (function () {
21
+ 'use strict';
22
+
23
+ function MagicTransportHttp(opts) {
24
+ opts = opts || {};
25
+ var BASE = String(opts.base || '').replace(/\/+$/, '');
26
+ var TOKEN = opts.token || '';
27
+
28
+ function hdr(extra) {
29
+ var h = extra || {};
30
+ if (TOKEN) h['X-Magic-Token'] = TOKEN;
31
+ return h;
32
+ }
33
+ function api(path, o) {
34
+ o = o || {};
35
+ o.headers = hdr(o.headers);
36
+ if (o.json !== undefined) {
37
+ o.method = o.method || 'POST';
38
+ o.headers['Content-Type'] = 'application/json';
39
+ o.body = JSON.stringify(o.json);
40
+ }
41
+ return fetch(BASE + path, o).then(function (r) {
42
+ if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
43
+ return r;
44
+ });
45
+ }
46
+
47
+ return {
48
+ /* 引擎清单:带模型(选择器要用),所以固定 ?models=1。 */
49
+ listEngines: function () {
50
+ return api('/desk/engines?models=1').then(function (r) { return r.json(); });
51
+ },
52
+
53
+ /* 一轮:把 SSE 拆成三个回调交给组件,组件不关心里面是 SSE 还是别的。 */
54
+ ask: function (payload, h) {
55
+ var aborted = false;
56
+ var ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
57
+ var ended = false;
58
+ var finish = function (code) {
59
+ if (ended) return;
60
+ ended = true;
61
+ if (h && h.onEnd) h.onEnd(code);
62
+ };
63
+ api('/desk/ask', { json: payload, signal: ctl ? ctl.signal : undefined })
64
+ .then(function (resp) {
65
+ var reader = resp.body.getReader(), dec = new TextDecoder(), buf = '';
66
+ return (function pump() {
67
+ return reader.read().then(function (chunk) {
68
+ if (chunk.done) return finish(0);
69
+ buf += dec.decode(chunk.value, { stream: true });
70
+ var blocks = buf.split('\n\n');
71
+ buf = blocks.pop();
72
+ blocks.forEach(function (b) {
73
+ var name = '', data = '';
74
+ b.split('\n').forEach(function (ln) {
75
+ if (ln.indexOf('event: ') === 0) name = ln.slice(7).trim();
76
+ else if (ln.indexOf('data: ') === 0) data += ln.slice(6);
77
+ });
78
+ if (!data) return;
79
+ var ev; try { ev = JSON.parse(data); } catch (e) { return; }
80
+ if (name === 'open') {
81
+ if (h && h.onOpen) h.onOpen(ev && (ev.id || ev.ask || ev.run));
82
+ return;
83
+ }
84
+ if (name === 'end') { finish(ev && ev.code); return; }
85
+ if (h && h.onEvent) h.onEvent(ev);
86
+ });
87
+ if (ended) return;
88
+ return pump();
89
+ });
90
+ })();
91
+ })
92
+ .catch(function (e) {
93
+ /* 被中止不算错(用户点了打断),别把它报成发送失败。 */
94
+ if (aborted) return finish(null);
95
+ if (h && h.onError) h.onError(e);
96
+ finish(null);
97
+ });
98
+ return { cancel: function () { aborted = true; if (ctl) ctl.abort(); } };
99
+ },
100
+
101
+ control: function (p) {
102
+ var body = { op: p.op };
103
+ if (p.text != null) body.text = p.text;
104
+ return api('/desk/ask/' + encodeURIComponent(p.id) + '/control', { json: body })
105
+ .then(function () { return true; });
106
+ },
107
+
108
+ readSession: function (id, o) {
109
+ o = o || {};
110
+ /* 有游标就走增量接口:长会话下差一个数量级。
111
+ 形状与全量接口不同(多 lastSeq / snapshotRequired),一并原样交给组件。 */
112
+ if (o.after != null) {
113
+ return api('/desk/session/' + encodeURIComponent(id) + '/events?after=' + o.after)
114
+ .then(function (r) { return r.json(); });
115
+ }
116
+ return api('/desk/session/' + encodeURIComponent(id) + '/messages')
117
+ .then(function (r) { return r.json(); });
118
+ }
119
+ };
120
+ }
121
+
122
+ window.MagicTransportHttp = MagicTransportHttp;
123
+ })();
@@ -0,0 +1,93 @@
1
+ Copyright 2014 The Caveat Project Authors (https://github.com/googlefonts/caveat)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,173 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <title>掌天瓶 · 对话</title>
7
+ <!--
8
+ 移动端宿主:把**同一个**公共对话组件(shared/conversation.js)挂到页面上。
9
+ 与 PC 那份是同一份实现 —— 差别只有两个:这个文件里的颜色令牌用的是
10
+ agents-anywhere 安卓端(Jetpack Compose)那套十六进制值,以及多了一条
11
+ 移动端形态的顶栏。
12
+
13
+ ── 顶栏为什么还在宿主里 ──
14
+ 组件只负责对话区(消息流 + 输入框 + 三项选择器)。标题栏、返回、主题这些
15
+ 属于宿主形态,PC 与移动端的形态本来就不一样,塞进组件反而会让两边互相牵制。
16
+
17
+ ── 视觉基准(逐值抄自 AA 安卓端的 Compose 源码,不是目测)──
18
+ designsystem/Theme.kt → 全部色令牌(暗色 canvas #09090B / 亮色 #FDFCFB 等)
19
+ SessionDetailScreen.kt → 顶栏 BackIconButton / TitlePill 的尺寸
20
+ 对话区本身(气泡 / markdown / 工具卡 / 思考块 / 审批卡)在共享组件里,
21
+ 那里同样标了对应的 Compose 行号。
22
+
23
+ ── 公共组件在哪 ──
24
+ 对话区在 ../client-ui/shared/ 下(PC 与移动端同一份实现)。这一页**按同级
25
+ 路径 shared/ 引用**:打包时由 magic-test 的 tools/build-mobile-www.cjs 把
26
+ client-ui/shared 一起复制进 www/shared —— 所以产物里是这个相对关系。
27
+ 直接拿浏览器开这个文件时,请让静态服务器同时暴露 client-ui-mobile/ 与
28
+ client-ui/shared(或在临时目录里拼出同样的同级关系),否则共享模块会 404。
29
+ -->
30
+ <link rel="stylesheet" href="shared/conversation.css">
31
+ <style>
32
+ /* ===== 令牌:AA 安卓端那一套,映射到共享组件的颜色契约 =====
33
+ ⚠️ 默认 = 暗色:跟 anywhere 的 DarkAgentsAnywhereColors 逐字段对齐。
34
+ 用户在系统里选了亮色才切亮色(没设主题时不会莫名跑出白底)。 */
35
+ :root{
36
+ color-scheme:dark;
37
+ --canvas:#09090B;
38
+ --ink:#FAFAFA; --ink-soft:#E5E5E5; --muted:#A3A3A3; --faint:#737373;
39
+ --border:rgba(255,255,255,.09); --subtle:#292929; --raised:#1F1F1F;
40
+ --primary-action:#FAFAFA; --on-primary-action:#09090B;
41
+ --app-shadow:rgba(0,0,0,.32);
42
+
43
+ --bubble:#2A2A2A; --bubble-text:#F4F4F5;
44
+ --code:#111113; --status-neutral:#2D2D2D; --status-neutral-text:#D4D4D8;
45
+ --activity:rgba(24,24,27,.063);
46
+
47
+ --md-body:#EDEDEF; --md-muted:#A1A1AA; --md-border:#27272A; --md-code-bg:#292929;
48
+ --link:#EBB353; --prompt:#E8798F; --shimmer:#FFFFFF;
49
+ --panel:#1F1F1F; --panel-border:#27272A;
50
+
51
+ --accent:#39A887; --warning:#F59E0B; --success:#22C55E;
52
+ --error-surface:#2A1214; --error-border:#5F2429; --error-text:#FCA5A5; --error-icon:#F97066;
53
+ --running:#10B981; --transitioning:#3B82F6; --active:#F59E0B;
54
+
55
+ /* 移动端沿用 AA 的三档圆角(18 / 12 / full),与 Compose 规格一致 */
56
+ --r-card:18px; --r:12px; --r-full:999px;
57
+ --font:-apple-system,BlinkMacSystemFont,"PingFang SC","Noto Sans CJK SC",system-ui,sans-serif;
58
+ --mono:ui-monospace,SFMono-Regular,Menlo,"SF Mono",monospace;
59
+ }
60
+ @media (prefers-color-scheme:light){
61
+ :root{
62
+ color-scheme:light;
63
+ --canvas:#FDFCFB;
64
+ --ink:#0A0A0B; --ink-soft:#2B2B2B; --muted:#777777; --faint:#AAA8A2;
65
+ --border:#E7E5E0; --subtle:#F1F0ED; --raised:#FFFFFF;
66
+ --primary-action:#0A0A0B; --on-primary-action:#FFFFFF;
67
+ --app-shadow:rgba(0,0,0,.13);
68
+ --bubble:#F1F0ED; --bubble-text:#242522;
69
+ --code:rgba(255,255,255,.72); --status-neutral:#E4E2DD; --status-neutral-text:#6F6E69;
70
+ --activity:rgba(241,240,237,.08);
71
+ --md-body:#242522; --md-muted:#7C7B76; --md-border:#E4E1DB; --md-code-bg:#F1F0ED;
72
+ --link:#EBB353; --prompt:#C45D74; --shimmer:#000000;
73
+ --panel:#ECECEA; --panel-border:transparent;
74
+ --accent:#18866A; --warning:#D97706; --success:#16A34A;
75
+ --error-surface:#FFF4F4; --error-border:#F4C7C7; --error-text:#B42318; --error-icon:#B42318;
76
+ }
77
+ }
78
+
79
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
80
+ /* ⚠️ 锁死视口高度而不是 min-height:用 min-height 时 body 会被内容撑高,
81
+ 滚动条落到 document 上,.conv__thread 的 overflow-y:auto 就永远不触发 ——
82
+ 后果是打开会话看不到顶部,也做不了「回到底部」。 */
83
+ html,body{margin:0;height:100%;overflow:hidden}
84
+ body{background:var(--canvas);color:var(--ink);font:15px/1.45 var(--font);
85
+ overscroll-behavior:none;display:flex;flex-direction:column}
86
+ button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
87
+ svg{display:block}
88
+
89
+ /* ── 顶栏 SessionDetailScreen:BackIconButton 40dp 圆 · TitlePill 34dp 胶囊 ── */
90
+ .detailbar{display:grid;grid-template-columns:40px 1fr 40px;align-items:center;gap:8px;
91
+ padding:calc(env(safe-area-inset-top) + 10px) 16px 8px;flex:0 0 auto}
92
+ .iconbtn{width:40px;height:40px;border-radius:var(--r-full);display:flex;align-items:center;
93
+ justify-content:center;background:var(--raised);color:var(--ink);border:1px solid var(--border)}
94
+ .titlepill{justify-self:center;max-width:100%;height:34px;display:flex;align-items:center;
95
+ padding:0 14px;border:1px solid var(--border);border-radius:var(--r-full);background:var(--raised);
96
+ font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
97
+
98
+ /* 组件挂载点:占满剩余高度 */
99
+ #conv{flex:1;min-height:0;display:flex}
100
+
101
+ /* 挂载失败时说明原因,而不是留一片空白 */
102
+ .boot-err{margin:16px;padding:14px;border-radius:14px;background:var(--error-surface);
103
+ border:1.2px solid var(--error-border);color:var(--error-text);font-size:13.5px;line-height:1.55}
104
+
105
+ /* 底部安全区:iPhone 的 home indicator 会压住输入框 */
106
+ .composer{padding-bottom:calc(16px + env(safe-area-inset-bottom))}
107
+
108
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
109
+ </style>
110
+ </head>
111
+ <body>
112
+
113
+ <svg width="0" height="0" style="position:absolute" aria-hidden="true">
114
+ <symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5 8 12l7 7"/></symbol>
115
+ <symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5.5v13M5.5 12h13"/></symbol>
116
+ </svg>
117
+
118
+ <div class="detailbar">
119
+ <button class="iconbtn" id="btnNew" aria-label="新会话"><svg width="20" height="20"><use href="#i-plus"/></svg></button>
120
+ <span class="titlepill" id="title">新会话</span>
121
+ <span></span>
122
+ </div>
123
+ <div id="conv"></div>
124
+
125
+ <script src="shared/conversation.js"></script>
126
+ <script src="shared/transport-http.js"></script>
127
+ <script>
128
+ (function () {
129
+ 'use strict';
130
+
131
+ /* 基址与令牌:两种宿主。
132
+ · 打包进 App(Capacitor):页面是本地文件,地址与令牌由 boot.js 从配对信息注入 ——
133
+ 那时 window.MAGIC_BASE 非空,所有 /desk/* 请求要前缀到桌面地址上;
134
+ · 浏览器同源打开(调试):不带 MAGIC_BASE,token 在 ?token= 上。 */
135
+ var TOKEN = window.MAGIC_TOKEN || new URLSearchParams(location.search).get('token') || '';
136
+ var BASE = (window.MAGIC_BASE || '').replace(/\/+$/, '');
137
+
138
+ var titleEl = document.getElementById('title');
139
+
140
+ function showBootError(msg) {
141
+ var d = document.createElement('div');
142
+ d.className = 'boot-err';
143
+ d.textContent = msg;
144
+ document.getElementById('conv').appendChild(d);
145
+ }
146
+
147
+ /* 打包进 App 时页面在本地(capacitor://localhost 或 file://),
148
+ 不能直接 fetch 本地相对路径 —— 下面这层把 <img src> 之类的相对资源
149
+ 前缀成桌面地址。与 boot.js 的垫片同一思路,只是这里组件不产生 img,
150
+ 所以只需要保证 fetch 走 BASE(组件已按 opts.base 处理)。 */
151
+
152
+ var conv = null;
153
+ (async function boot() {
154
+ try {
155
+ /* 传输层由宿主注入 —— 组件本身不连后端。
156
+ 打包进 App 时这里会换成「走自己后端」的实现;当前先用 HTTP 参考件。 */
157
+ var transport = window.MagicTransportHttp({ base: BASE, token: TOKEN });
158
+ conv = window.MagicConversation.create({
159
+ el: document.getElementById('conv'),
160
+ transport: transport,
161
+ titleEl: titleEl,
162
+ });
163
+ document.getElementById('btnNew').onclick = function () { conv.newSession(); };
164
+ /* 供对接方/调试取用:window.magicConv.setSession(sid) 换会话 */
165
+ window.magicConv = conv;
166
+ } catch (e) {
167
+ showBootError('对话组件挂载失败:' + (e && e.message ? e.message : e));
168
+ }
169
+ })();
170
+ })();
171
+ </script>
172
+ </body>
173
+ </html>
@@ -18,10 +18,17 @@ package agent
18
18
  // 归谁所有:models.json 是 **CLI 自己的**配置文件(CLI 的 models 控制器会读写它)。
19
19
  // 所以这里只做**合并**:只补缺失的 id,不删、不改、不动其它字段;无新增时不落盘
20
20
  //(避免无谓写盘与 mtime 抖动)。App 缓存一律只读。
21
+ //
22
+ // ⚠️ 硬约束(2026-09-30 补):目标文件**必须是顶层 JSON 对象**才动它。桌面端
23
+ // WorkBuddy 自己那份 `models.json` 顶层是**数组**(LanguageModel[]),与 CLI 的
24
+ // `{availableModels, models}` 不是同一个格式。若把数组当"解析失败的空文件"整体写回,
25
+ // 就是把对方的模型清单替换成只剩 custom-local 几条 —— 官方模型全丢且不可回滚。
26
+ // 撞上这种文件一律**跳过并在 stderr 说明**;覆盖写之前另留 `.magic-agent.bak` 原文。
21
27
 
22
28
  import (
23
29
  "encoding/json"
24
30
  "errors"
31
+ "fmt"
25
32
  "io/fs"
26
33
  "os"
27
34
  "path/filepath"
@@ -32,6 +39,10 @@ import (
32
39
  // customModelsFileName CLI 读自定义模型端点的文件名(见 CLI 的 getModelsPath)。
33
40
  const customModelsFileName = "models.json"
34
41
 
42
+ // customModelsBackupSuffix 覆盖写之前留的原文备份后缀。
43
+ // 一次误写就能把模型清单清空,留一份可回滚的东西。
44
+ const customModelsBackupSuffix = ".magic-agent.bak"
45
+
35
46
  // customModelIDPrefix 自定义模型 id 前缀(App 与 CLI 共用这个约定)。
36
47
  const customModelIDPrefix = "custom-local:"
37
48
 
@@ -53,15 +64,29 @@ func syncCustomModelsFromApp(accConfigPath, configDir string) (int, error) {
53
64
  }
54
65
 
55
66
  path := filepath.Join(configDir, customModelsFileName)
67
+ var raw0 []byte // 改动前的原文(覆盖写之前留备份)
56
68
  root := map[string]any{}
57
69
  switch b, err := os.ReadFile(path); {
58
70
  case err == nil:
59
- // 解析失败就当空文件:CLI 自己也会这么兜(readModelsJson 失败返回 {models:[]})。
60
- // 但**不覆盖**未识别的字段 —— 解析失败时下面只会写回 models/availableModels。
61
- _ = json.Unmarshal(b, &root)
62
- if root == nil {
63
- root = map[string]any{}
71
+ raw0 = b
72
+ /* ⚠️ 只有**顶层是 JSON 对象**才认作 CLI 的 models.json。
73
+ 桌面端(WorkBuddy)自己那份 models.json 顶层是**数组**(LanguageModel[]),
74
+ 数组解析不进 map[string]any。以前这里把"解析失败"当空文件继续写回
75
+ {models, availableModels} —— 对数组格式来说那等于把对方的模型清单**整体替换**掉:
76
+ 官方模型全没了,且不可恢复(原注释里"解析失败时不覆盖未识别的字段"对数组不成立,
77
+ 数组根本装不下未知字段)。这种文件不是 CLI 的格式,一律不动。 */
78
+ var doc any
79
+ if err := json.Unmarshal(b, &doc); err != nil {
80
+ fmt.Fprintf(os.Stderr, "magic-agent: 跳过自定义模型端点同步:%s 不是合法 JSON(保持原文件不动)\n", path)
81
+ return 0, nil
82
+ }
83
+ m, ok := doc.(map[string]any)
84
+ if !ok {
85
+ fmt.Fprintf(os.Stderr, "magic-agent: 跳过自定义模型端点同步:%s 顶层不是 JSON 对象"+
86
+ "(该文件不是 CLI 的 models.json 格式,覆盖写会毁掉里面的模型清单;保持原文件不动)\n", path)
87
+ return 0, nil
64
88
  }
89
+ root = m
65
90
  case errors.Is(err, fs.ErrNotExist):
66
91
  // 首次同步:新建
67
92
  default:
@@ -126,6 +151,10 @@ func syncCustomModelsFromApp(accConfigPath, configDir string) (int, error) {
126
151
  return 0, err
127
152
  }
128
153
  // 含第三方 apiKey:与 CLI 自身凭据同级(仅属主可读)。
154
+ // 覆盖写之前留一份原文备份:一次误写就能清空模型清单,必须可回滚。
155
+ if len(raw0) > 0 {
156
+ _ = os.WriteFile(path+customModelsBackupSuffix, raw0, 0o600)
157
+ }
129
158
  if err := os.WriteFile(path, b, 0o600); err != nil {
130
159
  return 0, err
131
160
  }
@@ -195,3 +224,120 @@ func (c codebuddyCore) effectiveConfigDir() string {
195
224
  }
196
225
  return c.configDir
197
226
  }
227
+
228
+ // ── 事故还原:把被覆盖写坏的桌面端 models.json 修回来 ──────────
229
+ //
230
+ // 背景(2026-09-30 Windows 事故):Windows 上 CODEBUDDY_CONFIG_DIR 可能指向桌面端
231
+ // 数据目录,于是上面的同步把桌面端那份**数组格式**的 models.json 覆盖成了
232
+ // `{availableModels, models}` 对象格式。桌面端解析不了 → 模型清单整个为空,
233
+ // 用户看到「WorkBuddy 的模型都丢失了」。
234
+ //
235
+ // 还原依据(实测比对同一台机器上的两份文件):
236
+ //
237
+ // 桌面端数组条目 {id:"MiniMax-M3", name, vendor, url, apiKey, ...}
238
+ // 我们写的对象条目 {id:"custom-local:MiniMax-M3", name, vendor, url, apiKey, ...}
239
+ //
240
+ // 即**同一批条目、id 差一个 custom-local: 前缀**。所以还原 = 把对象里的 models[]
241
+ // 原样搬成数组,只去掉 id 前缀。官方模型清单不在这个文件里(那是 product.json /
242
+ // acc 缓存的事,桌面端自己拉),所以这里不需要、也不该造官方条目。
243
+
244
+ // DesktopAppHomeOf 返回引擎对应的桌面端数据目录名(空 = 该引擎没有桌面端)。
245
+ func DesktopAppHomeOf(engine string) string {
246
+ switch strings.ToLower(strings.TrimSpace(engine)) {
247
+ case "codebuddy":
248
+ return ".workbuddy"
249
+ case "codebuddy-ai":
250
+ return ".workbuddy-ai"
251
+ }
252
+ return ""
253
+ }
254
+
255
+ // DesktopEngines 列出有桌面端数据目录的引擎(`--repair-models` 的可用值,供提示与校验共用)。
256
+ func DesktopEngines() []string { return []string{"codebuddy", "codebuddy-ai"} }
257
+
258
+ // RepairDesktopModels 修复被反向同步写坏的桌面端 models.json。
259
+ // 返回(是否真的做了修复, 给人看的说明, 错误)。没有可修的东西时返回 (false, 说明, nil)。
260
+ func RepairDesktopModels(engine string) (bool, string, error) {
261
+ home := DesktopAppHomeOf(engine)
262
+ if home == "" {
263
+ return false, "", fmt.Errorf(
264
+ "引擎 %q 没有对应的桌面端数据目录,没有可修的 models.json(支持:%s)",
265
+ strings.TrimSpace(engine), strings.Join(DesktopEngines(), " / "))
266
+ }
267
+ dir, err := os.UserHomeDir()
268
+ if err != nil || dir == "" {
269
+ dir = os.Getenv("HOME")
270
+ }
271
+ if dir == "" {
272
+ return false, "", fmt.Errorf("定位用户目录失败,无法找到桌面端 models.json")
273
+ }
274
+ return repairModelsFile(filepath.Join(dir, home))
275
+ }
276
+
277
+ // repairModelsFile 就地把 appHome/models.json 从「被覆盖写的对象」还原成桌面端数组。
278
+ //
279
+ // 只认**明确的坏形状**(顶层对象 + models[] 全是 custom-local:* 条目)才动手:
280
+ // 已经是数组、不是合法 JSON、或形状不认识,一律原样不动并说明原因。
281
+ func repairModelsFile(appHome string) (bool, string, error) {
282
+ path := filepath.Join(appHome, customModelsFileName)
283
+ b, err := os.ReadFile(path)
284
+ switch {
285
+ case errors.Is(err, fs.ErrNotExist):
286
+ return false, fmt.Sprintf("%s 不存在,无需修复", path), nil
287
+ case err != nil:
288
+ return false, "", err
289
+ }
290
+
291
+ var doc any
292
+ if uerr := json.Unmarshal(b, &doc); uerr != nil {
293
+ return false, fmt.Sprintf("%s 不是合法 JSON,形状对不上,未改动", path), nil
294
+ }
295
+ if _, isArr := doc.([]any); isArr {
296
+ return false, fmt.Sprintf("%s 已是桌面端的数组格式,无需修复", path), nil
297
+ }
298
+ root, ok := doc.(map[string]any)
299
+ if !ok {
300
+ return false, fmt.Sprintf("%s 顶层既不是数组也不是对象,未改动", path), nil
301
+ }
302
+ raw, _ := root["models"].([]any)
303
+ if len(raw) == 0 {
304
+ return false, fmt.Sprintf("%s 里没有 models 数组,不像被我们覆盖写的形状,未改动", path), nil
305
+ }
306
+
307
+ out := make([]any, 0, len(raw))
308
+ for _, it := range raw {
309
+ m, ok := it.(map[string]any)
310
+ if !ok {
311
+ return false, fmt.Sprintf("%s 的 models 里混有非对象条目,未改动", path), nil
312
+ }
313
+ id, _ := m["id"].(string)
314
+ if !strings.HasPrefix(id, customModelIDPrefix) {
315
+ return false, fmt.Sprintf("%s 的 models 里混有非 %s 条目(可能不是我们写坏的),未改动",
316
+ path, customModelIDPrefix), nil
317
+ }
318
+ cp := make(map[string]any, len(m))
319
+ for k, v := range m {
320
+ cp[k] = v
321
+ }
322
+ cp["id"] = strings.TrimPrefix(id, customModelIDPrefix)
323
+ out = append(out, cp)
324
+ }
325
+
326
+ nb, merr := json.MarshalIndent(out, "", " ")
327
+ if merr != nil {
328
+ return false, "", merr
329
+ }
330
+ nb = append(nb, '\n')
331
+
332
+ // 先把现场(被写坏的那份)留一份,再落盘。
333
+ broken := path + ".broken.bak"
334
+ if werr := os.WriteFile(broken, b, 0o600); werr != nil {
335
+ return false, "", fmt.Errorf("写备份 %s 失败: %w", broken, werr)
336
+ }
337
+ if werr := os.WriteFile(path, nb, 0o600); werr != nil {
338
+ return false, "", werr
339
+ }
340
+ return true, fmt.Sprintf(
341
+ "已还原 %s:%d 条自定义模型改回桌面端的数组格式(id 去掉 %s 前缀);原文件备份在 %s",
342
+ path, len(out), customModelIDPrefix, broken), nil
343
+ }