magic-agent 1.0.0 → 1.0.2
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 +298 -0
- package/README.md +35 -0
- package/client-ui/assets/OFL-Caveat.txt +93 -0
- package/client-ui/assets/caveat.woff2 +0 -0
- package/client-ui/index.html +246 -0
- package/client-ui/pc/app.css +5110 -0
- package/client-ui/pc/assets/damiao-mascot.png +0 -0
- package/client-ui/pc/assets/icon.png +0 -0
- package/client-ui/pc/index.html +154 -0
- package/client-ui/pc/js/00-base.js +52 -0
- package/client-ui/pc/js/03-chat-message.js +880 -0
- package/client-ui/pc/js/04-task-list.js +430 -0
- package/client-ui/pc/js/05-task-list-render.js +812 -0
- package/client-ui/pc/js/06-ask-answer.js +204 -0
- package/client-ui/pc/js/07-chat-stream-render.js +493 -0
- package/client-ui/pc/js/08-ask-event-map.js +171 -0
- package/client-ui/pc/js/09-stream-apply.js +367 -0
- package/client-ui/pc/js/10-markdown.js +430 -0
- package/client-ui/pc/js/18-askbox.js +262 -0
- package/client-ui/pc/js/19-attachments.js +679 -0
- package/client-ui/pc/js/29-msg-variants.js +462 -0
- package/client-ui/pc/js/90-glue.js +212 -0
- package/client-ui/pc/js/91-workbench-stubs.js +134 -0
- package/client-ui/pc/js/92-composer.js +406 -0
- package/client-ui/pc/js/93-rail.js +362 -0
- package/client-ui/pc/vendor/highlight.min.js +1262 -0
- package/client-ui/pc/vendor/marked.min.js +6 -0
- package/client-ui/pc/vendor/purify.min.js +3 -0
- package/client-ui/shared/conversation.css +256 -0
- package/client-ui/shared/conversation.js +1225 -0
- package/client-ui/shared/transport-http.js +123 -0
- package/client-ui-mobile/assets/OFL-Caveat.txt +93 -0
- package/client-ui-mobile/assets/caveat.woff2 +0 -0
- package/client-ui-mobile/index.html +173 -0
- package/npm/bin/magic-agent.js +43 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/package.json +4 -1
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
/* 对话 UI 组件的公共逻辑(PC 与移动端共用这一份,是**唯一来源**)。
|
|
2
|
+
*
|
|
3
|
+
* 里面的 markdown 渲染、工具卡、思考块都是逐行对着 agents-anywhere 的 Compose 源码写的,
|
|
4
|
+
* 最早由脚本从 client-ui-mobile 原样抽取而来;抽取完成后两个宿主都收敛成了瘦壳,
|
|
5
|
+
* 这份文件就是权威实现 —— 改对话行为改这里,不要再去改宿主。
|
|
6
|
+
*
|
|
7
|
+
* ── 组件不连后端 ──
|
|
8
|
+
* magic-agent **自己不跑服务**。数据从哪来由宿主决定,组件只认一个 transport:
|
|
9
|
+
* listEngines() -> Promise<[{engine, ok, models, capabilities}]>
|
|
10
|
+
* ask(payload, {onOpen,onEvent,onEnd}) -> 无返回;onEnd **必须**被调一次
|
|
11
|
+
* payload: {engine, model?, permission?, session?, prompt}
|
|
12
|
+
* onOpen(runID) / onEvent(coreEvent) / onEnd(exitCode)
|
|
13
|
+
* control({id, op, text?}) -> Promise (op: interrupt | stop | answer)
|
|
14
|
+
* readSession(id, {after?}) -> Promise<{events, lastSeq?, snapshotRequired?}>
|
|
15
|
+
* 掌天瓶走 IPC → CLI;别的对接方可能走 HTTP —— 组件对此一无所知。
|
|
16
|
+
* 现成的 HTTP 实现见同目录 transport-http.js。
|
|
17
|
+
*
|
|
18
|
+
* ── 用法 ──
|
|
19
|
+
* const conv = MagicConversation.create({
|
|
20
|
+
* el: 容器元素(组件会把对话区 + 输入区建进去)
|
|
21
|
+
* transport: 必填,见上
|
|
22
|
+
* session: 可选。传了就从这段会话开始(会拉历史),不传 = 新会话
|
|
23
|
+
* engine, model, permission: 可选初始值(空 = 不传,由 core 用自己的默认)
|
|
24
|
+
* engines: 可选。给了就用这份清单,不给则调 transport.listEngines()
|
|
25
|
+
* titleEl: 可选。宿主显示会话标题的元素(组件会写「新会话 · <引擎>」这类文案)
|
|
26
|
+
* onEvent: 可选。每个 core 事件透传一份给宿主(只读)
|
|
27
|
+
* onBusy: 可选。一轮开始/结束时回调 (busy:boolean)
|
|
28
|
+
* });
|
|
29
|
+
* conv.send('…'); conv.interrupt(); conv.stop(); conv.answer('…');
|
|
30
|
+
* conv.setSession(sid) / conv.newSession() / conv.refreshEngines() / conv.destroy();
|
|
31
|
+
*
|
|
32
|
+
* ── 边界 ──
|
|
33
|
+
* 组件**只管对话区**:会话列表、工作区切换、引擎探测的展示、业务视图都由对接方
|
|
34
|
+
* 自己通过它那侧的接口实现,再把选中的会话交给组件(setSession)。
|
|
35
|
+
*/
|
|
36
|
+
(function () {
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
40
|
+
|
|
41
|
+
/* 组件自带的图标集:宿主**不必**自己准备这些 symbol。
|
|
42
|
+
前 7 个与 client-ui-mobile 里的定义逐字相同(保持两端图标一致),
|
|
43
|
+
i-cpu / i-set 是选择器要用的,移动端原先没有,按其同一套线宽补画。 */
|
|
44
|
+
var SPRITE =
|
|
45
|
+
'<symbol id="i-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6.5 9.5 5.5 5.5 5.5-5.5"/></symbol>' +
|
|
46
|
+
'<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2.2"/><path d="M15 6.2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h.2"/></symbol>' +
|
|
47
|
+
'<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 3h-6M21 3v6M21 3l-8 8"/><path d="M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5"/></symbol>' +
|
|
48
|
+
'<symbol id="i-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.3a.6.6 0 0 1 1.1 0l1.3 3.7a.6.6 0 0 0 .4.4l3.7 1.3a.6.6 0 0 1 0 1.1l-3.7 1.3a.6.6 0 0 0-.4.4l-1.3 3.7a.6.6 0 0 1-1.1 0l-1.3-3.7a.6.6 0 0 0-.4-.4L4.5 10.8a.6.6 0 0 1 0-1.1l3.7-1.3a.6.6 0 0 0 .4-.4z"/><path d="M18 5.5v4M20 7.5h-4M17 16v3M18.5 17.5h-3"/></symbol>' +
|
|
49
|
+
'<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16.5h.01"/></symbol>' +
|
|
50
|
+
'<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>' +
|
|
51
|
+
'<symbol id="i-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19.5V5M5.8 11.2 12 5l6.2 6.2"/></symbol>' +
|
|
52
|
+
'<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="7" y="7" width="10" height="10" rx="2"/><path d="M10 4v3M14 4v3M10 17v3M14 17v3M4 10h3M4 14h3M17 10h3M17 14h3"/></symbol>' +
|
|
53
|
+
'<symbol id="i-set" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18 6l-1.6 1.6M7.6 16.4 6 18M18 18l-1.6-1.6M7.6 7.6 6 6"/></symbol>';
|
|
54
|
+
|
|
55
|
+
var spriteDone = false;
|
|
56
|
+
function ensureSprite() {
|
|
57
|
+
if (spriteDone) return;
|
|
58
|
+
spriteDone = true;
|
|
59
|
+
var s = document.createElementNS(SVG_NS, 'svg');
|
|
60
|
+
s.setAttribute('width', '0'); s.setAttribute('height', '0');
|
|
61
|
+
s.setAttribute('aria-hidden', 'true');
|
|
62
|
+
s.style.position = 'absolute';
|
|
63
|
+
s.innerHTML = SPRITE;
|
|
64
|
+
document.body.appendChild(s);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function create(opts) {
|
|
68
|
+
opts = opts || {};
|
|
69
|
+
var root = opts.el;
|
|
70
|
+
if (!root) throw new Error('MagicConversation.create: 缺 el(组件要挂的容器)');
|
|
71
|
+
/* 组件**不自己连后端**:magic-agent 不跑服务,数据从哪来由宿主决定
|
|
72
|
+
(掌天瓶走 IPC/CLI,别的对接方可能走 HTTP)。所以 transport 是必填的,
|
|
73
|
+
缺了就当场报错,而不是回退到某个默认地址 —— 那种回退会把
|
|
74
|
+
「宿主没接」伪装成「连不上」,最难查。 */
|
|
75
|
+
var transport = opts.transport;
|
|
76
|
+
if (!transport || typeof transport.ask !== 'function') {
|
|
77
|
+
throw new Error('MagicConversation.create: 缺 transport(需实现 listEngines / ask / control / readSession)');
|
|
78
|
+
}
|
|
79
|
+
ensureSprite();
|
|
80
|
+
|
|
81
|
+
/* ── 组件自有的 DOM 引用 ──
|
|
82
|
+
保留段落里的代码是按 $('thread') / $('input') 这类 id 写的,下面这张映射表
|
|
83
|
+
把它们指到组件自己的元素上 —— 于是那段代码**一字不用改**就能复用。 */
|
|
84
|
+
var ctx = { thread: null, inner: null, input: null, sendBtn: null, permBtn: null, bottomBtn: null,
|
|
85
|
+
titleEl: opts.titleEl || document.createElement('span') };
|
|
86
|
+
function $(id) {
|
|
87
|
+
if (id === 'thread') return ctx.thread;
|
|
88
|
+
if (id === 'input') return ctx.input;
|
|
89
|
+
if (id === 'btnSend') return ctx.sendBtn;
|
|
90
|
+
if (id === 'btnPerm') return ctx.permBtn;
|
|
91
|
+
if (id === 'btnBottom') return ctx.bottomBtn;
|
|
92
|
+
if (id === 'chatTitle') return ctx.titleEl;
|
|
93
|
+
return document.getElementById(id);
|
|
94
|
+
}
|
|
95
|
+
function el(tag, cls, text) {
|
|
96
|
+
var n = document.createElement(tag);
|
|
97
|
+
if (cls) n.className = cls;
|
|
98
|
+
if (text != null) n.textContent = text;
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
101
|
+
function svgIcon(id, size) {
|
|
102
|
+
var s = document.createElementNS(SVG_NS, 'svg');
|
|
103
|
+
s.setAttribute('width', size || 16); s.setAttribute('height', size || 16);
|
|
104
|
+
var u = document.createElementNS(SVG_NS, 'use');
|
|
105
|
+
u.setAttribute('href', '#' + id);
|
|
106
|
+
s.appendChild(u);
|
|
107
|
+
return s;
|
|
108
|
+
}
|
|
109
|
+
function box(cls, iconId, size) {
|
|
110
|
+
var d = el('span', cls);
|
|
111
|
+
d.appendChild(svgIcon(iconId, size));
|
|
112
|
+
return d;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
var state = {
|
|
116
|
+
engines: opts.engines || [], engine: opts.engine || '', model: opts.model || '',
|
|
117
|
+
permission: opts.permission || '', session: opts.session || '', busy: false, askId: null,
|
|
118
|
+
renderedSeq: {}, cur: null, agentRaw: '', rsn: null, rsnRaw: '', lastTool: null,
|
|
119
|
+
working: null, autoFollow: true, resultSeen: false
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/* ══════════ markdown 渲染 ══════════
|
|
123
|
+
对齐 AgentMarkdownText.kt:commonmark + autolink + strikethrough + tables + task list。
|
|
124
|
+
不引第三方库 —— 只覆盖 agent 回复里实际会出现的子集,够用且零依赖。 */
|
|
125
|
+
function esc(s) {
|
|
126
|
+
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
|
127
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// :937-942 parseFileRef —— 形如 src/app.ts:12 的路径引用
|
|
131
|
+
function fileRef(t) {
|
|
132
|
+
if (!t || /\s/.test(t) || t.indexOf('://') >= 0) return null;
|
|
133
|
+
if (t.indexOf('/') < 0) return null;
|
|
134
|
+
if (!/\.[a-zA-Z0-9]+(?::\d+(?::\d+)?)?$/.test(t)) return null;
|
|
135
|
+
return t.replace(/:\d+(?::\d+)?$/, '');
|
|
136
|
+
}
|
|
137
|
+
var COPY_TOKEN = '\u0000';
|
|
138
|
+
var HARD_BREAK = '\u0001';
|
|
139
|
+
var copyBin = [];
|
|
140
|
+
function copyAttr(text) {
|
|
141
|
+
copyBin.push(String(text == null ? '' : text));
|
|
142
|
+
return ' data-copy-idx="' + (copyBin.length - 1) + '"';
|
|
143
|
+
}
|
|
144
|
+
var COPY_BTN_HTML = '<button type="button" class="copybtn copybtn--sm" aria-label="复制"' + '\u0002' +
|
|
145
|
+
'><svg width="15" height="15"><use href="#i-copy"/></svg></button>';
|
|
146
|
+
|
|
147
|
+
function inlineMd(src) {
|
|
148
|
+
var tokens = [];
|
|
149
|
+
var s = String(src == null ? '' : src);
|
|
150
|
+
// 硬换行(行尾两空格 或 反斜杠)
|
|
151
|
+
s = s.replace(/\\\n/g, HARD_BREAK).replace(/ {2,}\n/g, HARD_BREAK);
|
|
152
|
+
// 行内码先摘出来,避免被转义与强调规则动到
|
|
153
|
+
s = s.replace(/`([^`]+)`/g, function (_, code) {
|
|
154
|
+
var isFile = fileRef(code);
|
|
155
|
+
tokens.push('<code class="md-code' + (isFile ? ' md-code--file' : '') +
|
|
156
|
+
(code.length > 30 || /\s/.test(code) ? ' md-code--wrap' : '') + '">' + esc(code) + '</code>');
|
|
157
|
+
return COPY_TOKEN + (tokens.length - 1) + COPY_TOKEN;
|
|
158
|
+
});
|
|
159
|
+
// markdown 链接也先摘出来
|
|
160
|
+
s = s.replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, function (_, text, href) {
|
|
161
|
+
tokens.push('<a href="' + esc(href) + '" target="_blank" rel="noreferrer noopener">' + esc(text) + '</a>');
|
|
162
|
+
return COPY_TOKEN + (tokens.length - 1) + COPY_TOKEN;
|
|
163
|
+
});
|
|
164
|
+
// 图片 → 只保留 alt 文字(:428)
|
|
165
|
+
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1');
|
|
166
|
+
s = esc(s);
|
|
167
|
+
// 尖括号自动链接
|
|
168
|
+
s = s.replace(/<((?:https?:\/\/|mailto:)[^\s&]+)>/g, function (_, url) {
|
|
169
|
+
return '<a href="' + url + '" target="_blank" rel="noreferrer noopener">' + url + '</a>';
|
|
170
|
+
});
|
|
171
|
+
// 裸 URL
|
|
172
|
+
s = s.replace(/(^|[\s((])((?:https?:\/\/)[^\s<>"'))]+)/g, function (_, pre, url) {
|
|
173
|
+
var tail = '';
|
|
174
|
+
while (/[.,;:!?]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); }
|
|
175
|
+
if (!url) return pre;
|
|
176
|
+
return pre + '<a href="' + url + '" target="_blank" rel="noreferrer noopener">' + url + '</a>' + tail;
|
|
177
|
+
});
|
|
178
|
+
s = s.replace(/\*\*([^\s*](?:[\s\S]*?[^\s*])?)\*\*/g, '<strong>$1</strong>');
|
|
179
|
+
s = s.replace(/__([^\s_](?:[\s\S]*?[^\s_])?)__/g, '<strong>$1</strong>');
|
|
180
|
+
s = s.replace(/~~([^\s~](?:[\s\S]*?[^\s~])?)~~/g, '<del>$1</del>');
|
|
181
|
+
s = s.replace(/(^|[^*\w])\*([^\s*](?:[^*]*?[^\s*])?)\*/g, '$1<em>$2</em>');
|
|
182
|
+
s = s.replace(/(^|[^_\w])_([^\s_](?:[^_]*?[^\s_])?)_/g, '$1<em>$2</em>');
|
|
183
|
+
s = s.split(HARD_BREAK).join('<br>').split('\n').join(' ');
|
|
184
|
+
s = s.replace(new RegExp(COPY_TOKEN + '(\\d+)' + COPY_TOKEN, 'g'), function (_, i) { return tokens[+i]; });
|
|
185
|
+
return s;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function splitCells(line) {
|
|
189
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return c.trim(); });
|
|
190
|
+
}
|
|
191
|
+
function isTableDelim(line) {
|
|
192
|
+
var c = splitCells(line);
|
|
193
|
+
return c.length >= 2 && c.every(function (x) { return /^:?-{1,}:?$/.test(x); });
|
|
194
|
+
}
|
|
195
|
+
function isTableHeader(line) {
|
|
196
|
+
if (isTableDelim(line)) return false;
|
|
197
|
+
var c = splitCells(line);
|
|
198
|
+
return c.length >= 2 && c.some(function (x) { return x !== ''; });
|
|
199
|
+
}
|
|
200
|
+
function readTable(lines, i) {
|
|
201
|
+
var header = splitCells(lines[i]);
|
|
202
|
+
var aligns = splitCells(lines[i + 1]).map(function (x) {
|
|
203
|
+
if (/^:-+:$/.test(x)) return 'center';
|
|
204
|
+
if (/^-+:$/.test(x)) return 'right';
|
|
205
|
+
return 'left';
|
|
206
|
+
});
|
|
207
|
+
i += 2;
|
|
208
|
+
var body = [];
|
|
209
|
+
while (i < lines.length && lines[i].trim() && lines[i].indexOf('|') >= 0) { body.push(splitCells(lines[i])); i++; }
|
|
210
|
+
var h = '<div class="md-table-wrap"><table><thead><tr>';
|
|
211
|
+
header.forEach(function (c, k) {
|
|
212
|
+
h += '<th style="text-align:' + (aligns[k] || 'left') + '">' + inlineMd(c) + '</th>';
|
|
213
|
+
});
|
|
214
|
+
h += '</tr></thead><tbody>';
|
|
215
|
+
body.forEach(function (r) {
|
|
216
|
+
h += '<tr>';
|
|
217
|
+
for (var k = 0; k < header.length; k++) {
|
|
218
|
+
h += '<td style="text-align:' + (aligns[k] || 'left') + '">' + inlineMd(r[k] || '') + '</td>';
|
|
219
|
+
}
|
|
220
|
+
h += '</tr>';
|
|
221
|
+
});
|
|
222
|
+
return { html: h + '</tbody></table></div>', next: i };
|
|
223
|
+
}
|
|
224
|
+
function readList(lines, i) {
|
|
225
|
+
var ordered = /^\s*\d+[.)]\s+/.test(lines[i]);
|
|
226
|
+
var m = lines[i].match(/^\s*(\d+)[.)]\s+/);
|
|
227
|
+
var start = m ? parseInt(m[1], 10) : 1;
|
|
228
|
+
var items = [], idx = 0;
|
|
229
|
+
while (i < lines.length) {
|
|
230
|
+
var mm = lines[i].match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/);
|
|
231
|
+
if (!mm) break;
|
|
232
|
+
var body = mm[1], task = null;
|
|
233
|
+
var tm = body.match(/^\[([ xX])\]\s+(.*)$/);
|
|
234
|
+
if (tm) { task = tm[1].toLowerCase() === 'x'; body = tm[2]; }
|
|
235
|
+
// 续行:缩进的普通行并到上一条
|
|
236
|
+
i++;
|
|
237
|
+
while (i < lines.length && lines[i].trim() && /^\s{2,}/.test(lines[i]) &&
|
|
238
|
+
!/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[i])) {
|
|
239
|
+
body += ' ' + lines[i].trim(); i++;
|
|
240
|
+
}
|
|
241
|
+
items.push({ task: task, text: body, num: ordered ? start + idx : null });
|
|
242
|
+
idx++;
|
|
243
|
+
}
|
|
244
|
+
var tag = ordered ? 'ol' : 'ul';
|
|
245
|
+
var h = '<' + tag + '>';
|
|
246
|
+
items.forEach(function (it) {
|
|
247
|
+
// :222-227 anywhere 的 bullet 前缀就是字面量 "-"(不是圆点);任务列表走 mono 的 [ ] / [x]
|
|
248
|
+
var mk = it.task === null ? (it.num != null ? it.num + '.' : '-') : (it.task ? '[x]' : '[ ]');
|
|
249
|
+
h += '<li><span class="mk' + (it.task !== null ? ' mk--mono' : '') + '">' + esc(mk) + '</span>' +
|
|
250
|
+
'<span class="li-b">' + inlineMd(it.text) + '</span></li>';
|
|
251
|
+
});
|
|
252
|
+
return { html: h + '</' + tag + '>', next: i };
|
|
253
|
+
}
|
|
254
|
+
var BASH_LABELS = { bash: 1, sh: 1, shell: 1, zsh: 1, console: 1, terminal: 1 };
|
|
255
|
+
function codePanelHtml(label, code) {
|
|
256
|
+
var isBash = !!BASH_LABELS[String(label || '').toLowerCase()];
|
|
257
|
+
var body;
|
|
258
|
+
if (isBash) {
|
|
259
|
+
// MarkdownCodePanel(:515-544) 走 BashCommandCard:按行拆,首个 token 上 prompt 色
|
|
260
|
+
var cmds = String(code).split('\n').map(function (x) { return x.replace(/\s+$/, ''); })
|
|
261
|
+
.filter(function (x) { return x.trim() !== ''; });
|
|
262
|
+
if (!cmds.length) cmds = [code];
|
|
263
|
+
body = cmds.map(function (c) {
|
|
264
|
+
var t = c.trim(), sp = t.indexOf(' ');
|
|
265
|
+
var first = sp < 0 ? t : t.slice(0, sp);
|
|
266
|
+
var rest = sp < 0 ? '' : t.slice(sp + 1).replace(/^\s+/, '');
|
|
267
|
+
return '<div class="cmdline"><span class="cmdline__p">' + esc(first) + '</span>' +
|
|
268
|
+
(rest ? '<span class="cmdline__r">' + esc(rest) + '</span>' : '') + '</div>';
|
|
269
|
+
}).join('');
|
|
270
|
+
} else {
|
|
271
|
+
body = '<pre class="codepanel__pre">' + esc(code) + '</pre>';
|
|
272
|
+
}
|
|
273
|
+
return '<div class="codepanel"><div class="codepanel__head">' +
|
|
274
|
+
'<span class="codepanel__label">' + esc(isBash ? 'Bash' : (label || 'code')) + '</span>' +
|
|
275
|
+
COPY_BTN_HTML.split('\u0002').join(copyAttr(code)) +
|
|
276
|
+
'</div><div class="codepanel__code">' + body + '</div></div>';
|
|
277
|
+
}
|
|
278
|
+
function mdToHtml(src) {
|
|
279
|
+
var lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
|
|
280
|
+
// :883-911 normalizeMarkdownTables:表格前一行不是空行时补一个空行
|
|
281
|
+
var norm = [], inFence = false, k;
|
|
282
|
+
for (k = 0; k < lines.length; k++) {
|
|
283
|
+
if (!inFence && k + 1 < lines.length && isTableHeader(lines[k]) && isTableDelim(lines[k + 1]) &&
|
|
284
|
+
norm.length && norm[norm.length - 1].trim() !== '') norm.push('');
|
|
285
|
+
norm.push(lines[k]);
|
|
286
|
+
if (/^\s*(```|~~~)/.test(lines[k])) inFence = !inFence;
|
|
287
|
+
}
|
|
288
|
+
var n = norm.length, i = 0, out = [];
|
|
289
|
+
while (i < n) {
|
|
290
|
+
var line = norm[i];
|
|
291
|
+
if (!line.trim()) { i++; continue; }
|
|
292
|
+
var fence = line.match(/^\s*(```|~~~)\s*(.*)$/);
|
|
293
|
+
if (fence) {
|
|
294
|
+
var mark = fence[1];
|
|
295
|
+
var label = (fence[2] || '').trim().split(/\s+/)[0] || 'code';
|
|
296
|
+
var buf = []; i++;
|
|
297
|
+
while (i < n && !new RegExp('^\\s*' + mark).test(norm[i])) { buf.push(norm[i]); i++; }
|
|
298
|
+
i++;
|
|
299
|
+
out.push(codePanelHtml(label, buf.join('\n')));
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
var h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
303
|
+
if (h) { out.push('<h' + h[1].length + '>' + inlineMd(h[2].trim()) + '</h' + h[1].length + '>'); i++; continue; }
|
|
304
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
|
|
305
|
+
if (/^\s*>/.test(line)) {
|
|
306
|
+
var qb = [];
|
|
307
|
+
while (i < n && /^\s*>/.test(norm[i])) { qb.push(norm[i].replace(/^\s*>\s?/, '')); i++; }
|
|
308
|
+
out.push('<blockquote><span class="bar"></span><div class="q-b">' + mdToHtml(qb.join('\n')) + '</div></blockquote>');
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) {
|
|
312
|
+
var rl = readList(norm, i); out.push(rl.html); i = rl.next; continue;
|
|
313
|
+
}
|
|
314
|
+
if (isTableHeader(line) && i + 1 < n && isTableDelim(norm[i + 1])) {
|
|
315
|
+
var rt = readTable(norm, i); out.push(rt.html); i = rt.next; continue;
|
|
316
|
+
}
|
|
317
|
+
var pb = [];
|
|
318
|
+
while (i < n && norm[i].trim() &&
|
|
319
|
+
!/^\s*(```|~~~)/.test(norm[i]) && !/^#{1,6}\s/.test(norm[i]) &&
|
|
320
|
+
!/^\s*>/.test(norm[i]) && !/^\s*(?:[-*+]|\d+[.)])\s+/.test(norm[i]) &&
|
|
321
|
+
!/^\s*([-*_])(\s*\1){2,}\s*$/.test(norm[i]) &&
|
|
322
|
+
!(isTableHeader(norm[i]) && i + 1 < n && isTableDelim(norm[i + 1]))) {
|
|
323
|
+
pb.push(norm[i]); i++;
|
|
324
|
+
}
|
|
325
|
+
if (pb.length) out.push('<p>' + inlineMd(pb.join('\n')) + '</p>');
|
|
326
|
+
else i++;
|
|
327
|
+
}
|
|
328
|
+
return out.join('');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* ══════════ 对话区组件 ══════════ */
|
|
332
|
+
function thread() { return $('thread'); }
|
|
333
|
+
function scrollBottom() { var t = thread(); t.scrollTop = t.scrollHeight; }
|
|
334
|
+
function nearBottom() { var t = thread(); return t.scrollHeight - t.scrollTop - t.clientHeight < 48; }
|
|
335
|
+
function pushItem(node, force) {
|
|
336
|
+
thread().appendChild(node);
|
|
337
|
+
if (force || state.autoFollow) scrollBottom();
|
|
338
|
+
}
|
|
339
|
+
function tgroup() { return el('div', 'tgroup'); }
|
|
340
|
+
function doCopy(text) {
|
|
341
|
+
var s = String(text == null ? '' : text);
|
|
342
|
+
try {
|
|
343
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(s);
|
|
344
|
+
else {
|
|
345
|
+
var ta = el('textarea');
|
|
346
|
+
ta.value = s; ta.style.cssText = 'position:fixed;left:-9999px';
|
|
347
|
+
document.body.appendChild(ta); ta.select();
|
|
348
|
+
try { document.execCommand('copy'); } catch (e) {}
|
|
349
|
+
document.body.removeChild(ta);
|
|
350
|
+
}
|
|
351
|
+
} catch (e) {}
|
|
352
|
+
}
|
|
353
|
+
function copyBtn(text) {
|
|
354
|
+
var b = el('button', 'copybtn');
|
|
355
|
+
b.type = 'button';
|
|
356
|
+
b.setAttribute('aria-label', '复制');
|
|
357
|
+
b.appendChild(svgIcon('i-copy', 17));
|
|
358
|
+
b.onclick = function () { doCopy(text); };
|
|
359
|
+
return b;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* UserBubble(:1286-1394) 22dp 圆角 · 17/13dp 内边距 · 16.5sp/24sp · 最多 8 行 */
|
|
363
|
+
function userBubble(text, failed) {
|
|
364
|
+
var wrap = tgroup();
|
|
365
|
+
var row = el('div', 'urow');
|
|
366
|
+
row.appendChild(copyBtn(text)); // :1332 左侧 30dp 圆复制按钮
|
|
367
|
+
var col = el('div', 'urow__col');
|
|
368
|
+
var bub = el('div', 'ubub');
|
|
369
|
+
var t = el('div', 'ubub__t', text);
|
|
370
|
+
bub.appendChild(t);
|
|
371
|
+
col.appendChild(bub);
|
|
372
|
+
if (failed) col.appendChild(el('div', 'ubub__meta', '发送失败'));
|
|
373
|
+
row.appendChild(col);
|
|
374
|
+
wrap.appendChild(row);
|
|
375
|
+
pushItem(wrap, true);
|
|
376
|
+
// :1352-1356 maxLines = 8,溢出时给「展开 / 收起」
|
|
377
|
+
var w = t.clientWidth;
|
|
378
|
+
if (w > 0) {
|
|
379
|
+
var probe = el('div', 'ubub__t');
|
|
380
|
+
probe.style.cssText = 'position:absolute;left:-99999px;top:0;visibility:hidden;' +
|
|
381
|
+
'display:block;max-height:none;overflow:visible;width:' + w + 'px';
|
|
382
|
+
probe.textContent = text;
|
|
383
|
+
document.body.appendChild(probe);
|
|
384
|
+
var full = probe.scrollHeight;
|
|
385
|
+
document.body.removeChild(probe);
|
|
386
|
+
if (full > 8 * 24 + 2) {
|
|
387
|
+
var btn = el('button', 'ubub__more', '展开');
|
|
388
|
+
btn.type = 'button';
|
|
389
|
+
btn.onclick = function () { btn.textContent = bub.classList.toggle('is-open') ? '收起' : '展开'; };
|
|
390
|
+
bub.appendChild(btn);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/* AgentMessageContent(:1238-1260) → AgentMarkdownText */
|
|
396
|
+
function ensureAgent() {
|
|
397
|
+
if (state.cur) return state.cur;
|
|
398
|
+
var wrap = tgroup();
|
|
399
|
+
var md = el('div', 'md');
|
|
400
|
+
wrap.appendChild(md);
|
|
401
|
+
pushItem(wrap);
|
|
402
|
+
state.cur = { wrap: wrap, md: md };
|
|
403
|
+
state.agentRaw = '';
|
|
404
|
+
return state.cur;
|
|
405
|
+
}
|
|
406
|
+
var mdTimer = 0;
|
|
407
|
+
function flushMd() {
|
|
408
|
+
mdTimer = 0;
|
|
409
|
+
if (!state.cur) return;
|
|
410
|
+
state.cur.md.innerHTML = mdToHtml(state.agentRaw);
|
|
411
|
+
if (state.autoFollow) scrollBottom();
|
|
412
|
+
}
|
|
413
|
+
function scheduleMd() {
|
|
414
|
+
if (mdTimer) return;
|
|
415
|
+
mdTimer = setTimeout(flushMd, 40); // 流式期间合并重绘,避免每个 chunk 都跑一遍 markdown
|
|
416
|
+
}
|
|
417
|
+
function finalizeAgent(withActions) {
|
|
418
|
+
if (!state.cur) return;
|
|
419
|
+
if (mdTimer) { clearTimeout(mdTimer); mdTimer = 0; }
|
|
420
|
+
flushMd();
|
|
421
|
+
if (withActions && state.agentRaw.trim()) state.cur.wrap.appendChild(replyActions(state.agentRaw));
|
|
422
|
+
state.cur = null; state.agentRaw = '';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* AgentReplyActions(:554-587) 分割线 + 复制 / 分享(各 30dp 圆) */
|
|
426
|
+
function replyActions(text) {
|
|
427
|
+
var d = el('div', 'acts');
|
|
428
|
+
d.appendChild(el('div', 'acts__line'));
|
|
429
|
+
var row = el('div', 'acts__row');
|
|
430
|
+
row.appendChild(copyBtn(text));
|
|
431
|
+
var share = el('button', 'copybtn');
|
|
432
|
+
share.type = 'button';
|
|
433
|
+
share.setAttribute('aria-label', '分享');
|
|
434
|
+
share.appendChild(svgIcon('i-share', 17));
|
|
435
|
+
share.onclick = function () {
|
|
436
|
+
var s = String(text || '');
|
|
437
|
+
if (navigator.share) { try { navigator.share({ text: s }); return; } catch (e) {} }
|
|
438
|
+
doCopy(s);
|
|
439
|
+
};
|
|
440
|
+
row.appendChild(share);
|
|
441
|
+
d.appendChild(row);
|
|
442
|
+
return d;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* ReasoningSection(:1530-1608) Sparkles + 13sp mono 标题 + 可折叠 */
|
|
446
|
+
function ensureRsn() {
|
|
447
|
+
if (state.rsn) return state.rsn;
|
|
448
|
+
var d = el('div', 'rsn');
|
|
449
|
+
var head = el('button', 'rsn__head');
|
|
450
|
+
head.type = 'button';
|
|
451
|
+
head.setAttribute('aria-expanded', 'false');
|
|
452
|
+
var chev = box('chev', 'i-chev', 16);
|
|
453
|
+
head.appendChild(chev);
|
|
454
|
+
head.appendChild(box('ico', 'i-spark', 16));
|
|
455
|
+
var title = el('span', 'rsn__title', '思考');
|
|
456
|
+
head.appendChild(title);
|
|
457
|
+
var body = el('div', 'rsn__body');
|
|
458
|
+
var md = el('div', 'md');
|
|
459
|
+
body.appendChild(md);
|
|
460
|
+
body.style.display = 'none';
|
|
461
|
+
head.onclick = function () {
|
|
462
|
+
// :1572 只有可展开时才挂点击;单段短摘要那种(plain)是纯展示,点不动
|
|
463
|
+
if (head.getAttribute('data-plain') === '1') return;
|
|
464
|
+
var open = head.getAttribute('aria-expanded') !== 'true';
|
|
465
|
+
head.setAttribute('aria-expanded', String(open));
|
|
466
|
+
body.style.display = open ? '' : 'none';
|
|
467
|
+
};
|
|
468
|
+
d.appendChild(head); d.appendChild(body);
|
|
469
|
+
pushItem(d);
|
|
470
|
+
state.rsn = { el: d, head: head, chev: chev, title: title, body: body, md: md, plain: false };
|
|
471
|
+
state.rsnRaw = '';
|
|
472
|
+
return state.rsn;
|
|
473
|
+
}
|
|
474
|
+
function plainSummary(s) {
|
|
475
|
+
return String(s || '')
|
|
476
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
477
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
478
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
479
|
+
.replace(/[*_~#>]+/g, '')
|
|
480
|
+
.replace(/\s+/g, ' ')
|
|
481
|
+
.trim();
|
|
482
|
+
}
|
|
483
|
+
function updateRsnTitle() {
|
|
484
|
+
var m = state.rsn;
|
|
485
|
+
if (!m) return;
|
|
486
|
+
var segs = String(state.rsnRaw).split(/\n{2,}/).filter(function (x) { return x.trim(); });
|
|
487
|
+
var one = segs.length === 1 ? plainSummary(segs[0]) : null;
|
|
488
|
+
if (one && one.length <= 80) {
|
|
489
|
+
// :1539-1545 单段且能压成一行 → 直接内联成摘要,不给折叠
|
|
490
|
+
m.title.textContent = '思考:' + one;
|
|
491
|
+
m.plain = true;
|
|
492
|
+
m.chev.style.display = 'none';
|
|
493
|
+
m.head.setAttribute('data-plain', '1');
|
|
494
|
+
m.head.setAttribute('aria-expanded', 'false');
|
|
495
|
+
m.body.style.display = 'none';
|
|
496
|
+
} else {
|
|
497
|
+
m.title.textContent = segs.length ? '思考 · ' + segs.length + ' 段' : '思考';
|
|
498
|
+
m.plain = false;
|
|
499
|
+
m.chev.style.display = '';
|
|
500
|
+
m.head.removeAttribute('data-plain');
|
|
501
|
+
}
|
|
502
|
+
// 不可展开时去掉「像能点」的反馈
|
|
503
|
+
m.head.style.cursor = m.plain ? 'default' : 'pointer';
|
|
504
|
+
}
|
|
505
|
+
function finalizeRsn() {
|
|
506
|
+
if (!state.rsn) return;
|
|
507
|
+
updateRsnTitle();
|
|
508
|
+
if (state.rsn.plain) state.rsn.body.style.display = 'none';
|
|
509
|
+
state.rsn = null; state.rsnRaw = '';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* WorkingIndicator(:1072-1120) */
|
|
513
|
+
function setWorking(label) {
|
|
514
|
+
clearWorking();
|
|
515
|
+
var d = el('div', 'working');
|
|
516
|
+
d.appendChild(el('div', 'working__sp'));
|
|
517
|
+
d.appendChild(el('span', 'working__t', label));
|
|
518
|
+
state.working = d;
|
|
519
|
+
pushItem(d);
|
|
520
|
+
}
|
|
521
|
+
function clearWorking() {
|
|
522
|
+
if (state.working) { state.working.remove(); state.working = null; }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/* ToolPlaceholder(:2199-2270) Clock / CircleAlert + 13sp Bold + 状态胶囊 */
|
|
526
|
+
function phItem(text, kind, status) {
|
|
527
|
+
var wrap = tgroup();
|
|
528
|
+
var d = el('div', 'ph' + (kind === 'err' ? ' ph--err' : ''));
|
|
529
|
+
var row = el('div', 'ph__row');
|
|
530
|
+
row.appendChild(box('ph__ico', kind === 'err' ? 'i-alert' : 'i-clock', 16));
|
|
531
|
+
row.appendChild(el('span', 'ph__t', text));
|
|
532
|
+
if (status) row.appendChild(el('span', 'pill' + (kind === 'err' ? ' pill--err' : ''), status));
|
|
533
|
+
d.appendChild(row);
|
|
534
|
+
wrap.appendChild(d);
|
|
535
|
+
return wrap;
|
|
536
|
+
}
|
|
537
|
+
/* CompactTimelineSeparator(:2272-2315) */
|
|
538
|
+
function sepItem(text) {
|
|
539
|
+
var wrap = tgroup();
|
|
540
|
+
var d = el('div', 'sep');
|
|
541
|
+
d.appendChild(el('div', 'sep__line'));
|
|
542
|
+
d.appendChild(el('span', 'sep__t', text));
|
|
543
|
+
d.appendChild(el('div', 'sep__line'));
|
|
544
|
+
wrap.appendChild(d);
|
|
545
|
+
return wrap;
|
|
546
|
+
}
|
|
547
|
+
/* AuthErrorNotice(Components.kt:88-117) */
|
|
548
|
+
function noticeItem(text) {
|
|
549
|
+
var d = el('div', 'notice');
|
|
550
|
+
d.appendChild(box('notice__ico', 'i-alert', 20));
|
|
551
|
+
d.appendChild(el('div', 'notice__t', text));
|
|
552
|
+
pushItem(d, true);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* ToolActivityCard(:1625-1748)
|
|
556
|
+
折叠行 8dp 圆角 + activitySurface + chevron + 16dp 图标 + 13sp mono 摘要
|
|
557
|
+
展开体 14dp 圆角 + subtle + 1dp 描边;Command 走 CommandLineBar,其余走 输入/输出 段落 */
|
|
558
|
+
function toolIconId(name) {
|
|
559
|
+
var n = String(name || '').toLowerCase();
|
|
560
|
+
if (/bash|shell|zsh|command|exec|run|terminal|\bcmd\b/.test(n)) return 'i-terminal';
|
|
561
|
+
if (/write|edit|read|file|glob|grep|patch|diff|apply|notebook/.test(n)) return 'i-filepen';
|
|
562
|
+
if (/agent|task|subagent|delegate/.test(n)) return 'i-bot';
|
|
563
|
+
return 'i-hammer';
|
|
564
|
+
}
|
|
565
|
+
function isCmdTool(name) {
|
|
566
|
+
return /bash|shell|zsh|command|exec|run|terminal|\bcmd\b/i.test(String(name || ''));
|
|
567
|
+
}
|
|
568
|
+
function toolCard(name, opts) {
|
|
569
|
+
opts = opts || {};
|
|
570
|
+
var cmd = isCmdTool(name);
|
|
571
|
+
var d = el('div', 'tool' + (cmd ? ' tool--cmd' : '') + (opts.failed ? ' tool--err' : ''));
|
|
572
|
+
var head = el('button', 'tool__head');
|
|
573
|
+
head.type = 'button';
|
|
574
|
+
head.setAttribute('aria-expanded', 'false');
|
|
575
|
+
head.appendChild(box('chev', 'i-chev', 16));
|
|
576
|
+
head.appendChild(box('tool__ico', toolIconId(name), 16));
|
|
577
|
+
var sum = el('span', 'tool__sum', String(opts.summary || name || '工具'));
|
|
578
|
+
head.appendChild(sum);
|
|
579
|
+
if (opts.pill) head.appendChild(el('span', 'pill' + (opts.failed ? ' pill--err' : ''), opts.pill));
|
|
580
|
+
d.appendChild(head);
|
|
581
|
+
var detail = el('div', 'tool__detail');
|
|
582
|
+
detail.style.display = 'none';
|
|
583
|
+
d.appendChild(detail);
|
|
584
|
+
head.onclick = function () {
|
|
585
|
+
var open = head.getAttribute('aria-expanded') !== 'true';
|
|
586
|
+
head.setAttribute('aria-expanded', String(open));
|
|
587
|
+
detail.style.display = open ? '' : 'none';
|
|
588
|
+
if (open) { try { if (navigator.vibrate) navigator.vibrate(12); } catch (e) {} } // :1656 触感
|
|
589
|
+
};
|
|
590
|
+
pushItem(d);
|
|
591
|
+
return { el: d, head: head, sum: sum, detail: detail, cmd: cmd };
|
|
592
|
+
}
|
|
593
|
+
/* CommandLineBar(:1969-2019) 14dp 圆角 · sessionCodeSurface · "$" + 13sp mono */
|
|
594
|
+
function cmdBar(label, command) {
|
|
595
|
+
var w = el('div', 'cmdbar');
|
|
596
|
+
var h = el('div', 'cmdbar__head');
|
|
597
|
+
h.appendChild(el('span', 'cmdbar__label', label || '命令'));
|
|
598
|
+
h.appendChild(copyBtn(command));
|
|
599
|
+
w.appendChild(h);
|
|
600
|
+
var b = el('div', 'cmdbar__body');
|
|
601
|
+
b.appendChild(el('span', 'cmdbar__p', '$'));
|
|
602
|
+
b.appendChild(el('span', 'cmdbar__t', command));
|
|
603
|
+
w.appendChild(b);
|
|
604
|
+
return w;
|
|
605
|
+
}
|
|
606
|
+
/* CommandPreviewSection(:2021-2052) 11sp Bold 标签 + 12dp 圆角代码块(min 64dp) */
|
|
607
|
+
function toolSection(label, text, isErr) {
|
|
608
|
+
var s = el('div', 'sect');
|
|
609
|
+
var h = el('div', 'sect__head');
|
|
610
|
+
h.appendChild(el('span', 'sect__label', label));
|
|
611
|
+
h.appendChild(copyBtn(text));
|
|
612
|
+
s.appendChild(h);
|
|
613
|
+
var c = el('div', 'sect__code');
|
|
614
|
+
c.appendChild(el('pre', 'sect__pre' + (isErr ? ' sect__pre--err' : ''), String(text == null ? '' : text)));
|
|
615
|
+
s.appendChild(c);
|
|
616
|
+
return s;
|
|
617
|
+
}
|
|
618
|
+
function stopPrevTool() {
|
|
619
|
+
if (state.lastTool) {
|
|
620
|
+
state.lastTool.sum.classList.remove('shimmer');
|
|
621
|
+
state.lastTool = null;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function onToolUse(ev) {
|
|
625
|
+
finalizeRsn();
|
|
626
|
+
finalizeAgent(false);
|
|
627
|
+
stopPrevTool();
|
|
628
|
+
var name = ev.name || '工具';
|
|
629
|
+
var input = String(ev.text == null ? '' : ev.text);
|
|
630
|
+
var head = input.split('\n')[0].trim();
|
|
631
|
+
var summary = head ? (name + ' · ' + head) : name;
|
|
632
|
+
if (summary.length > 120) summary = summary.slice(0, 120) + '…';
|
|
633
|
+
var card = toolCard(name, { summary: summary });
|
|
634
|
+
if (input) {
|
|
635
|
+
card.detail.appendChild(card.cmd ? cmdBar('命令', input) : toolSection('输入', input));
|
|
636
|
+
}
|
|
637
|
+
card.sum.classList.add('shimmer'); // :1704 运行中做扫光
|
|
638
|
+
state.lastTool = card;
|
|
639
|
+
}
|
|
640
|
+
function onToolResult(ev) {
|
|
641
|
+
var text = String(ev.text == null ? '' : ev.text);
|
|
642
|
+
var card = state.lastTool;
|
|
643
|
+
if (!card) card = toolCard('结果', { summary: '结果' });
|
|
644
|
+
var err = /^\s*(error|错误|失败)/i.test(text) || /\bexit code [1-9]/.test(text);
|
|
645
|
+
if (card.cmd && !card.detail.firstChild && !err) {
|
|
646
|
+
// 命令卡还没展开过命令本体时,把结果当输出段
|
|
647
|
+
card.detail.appendChild(cmdBar('命令', text));
|
|
648
|
+
} else {
|
|
649
|
+
card.detail.appendChild(toolSection('输出', text, err));
|
|
650
|
+
}
|
|
651
|
+
if (err) {
|
|
652
|
+
card.el.classList.add('tool--err');
|
|
653
|
+
card.head.appendChild(el('span', 'pill pill--err', 'failed'));
|
|
654
|
+
}
|
|
655
|
+
stopPrevTool();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/* ── 事件 → 界面(历史与实时共用同一条路径,靠 seq 去重) ── */
|
|
659
|
+
function render(ev) {
|
|
660
|
+
if (ev.seq != null && state.renderedSeq[ev.seq]) return;
|
|
661
|
+
if (ev.seq != null) state.renderedSeq[ev.seq] = true;
|
|
662
|
+
/* ⚠️ 两套字段名都要认:
|
|
663
|
+
实时 SSE(magic-agent --stream --events)事件用 `type`;
|
|
664
|
+
落盘的 <session_id>.jsonl 用 `kind`(internal/cli/sessionlog.go 的 sessionEvent.Kind)。
|
|
665
|
+
只认 type 的后果是「从列表点进会话」历史整段空白 —— 静默失败,最难查。 */
|
|
666
|
+
var t = ev.type || ev.kind;
|
|
667
|
+
if (t === 'ready') {
|
|
668
|
+
setWorking('Agent 运行中');
|
|
669
|
+
} else if (t === 'thinking') {
|
|
670
|
+
var m = ensureRsn();
|
|
671
|
+
state.rsnRaw += ev.text || '';
|
|
672
|
+
m.md.innerHTML = mdToHtml(state.rsnRaw);
|
|
673
|
+
updateRsnTitle();
|
|
674
|
+
if (state.autoFollow) scrollBottom();
|
|
675
|
+
} else if (t === 'text') {
|
|
676
|
+
finalizeRsn();
|
|
677
|
+
ensureAgent();
|
|
678
|
+
state.agentRaw += ev.text || '';
|
|
679
|
+
scheduleMd();
|
|
680
|
+
} else if (t === 'tool_use') {
|
|
681
|
+
onToolUse(ev);
|
|
682
|
+
} else if (t === 'tool_result') {
|
|
683
|
+
onToolResult(ev);
|
|
684
|
+
} else if (t === 'user') {
|
|
685
|
+
// 历史里「用户自己那条」。core 目前只落引擎事件,这条是给落盘后预留的;
|
|
686
|
+
// 认它不花成本,落盘一上线历史就自然有气泡。
|
|
687
|
+
finalizeRsn(); finalizeAgent(true); stopPrevTool(); clearWorking();
|
|
688
|
+
userBubble(String(ev.text || ''), false);
|
|
689
|
+
} else if (t === 'turn_end') {
|
|
690
|
+
if (ev.session_id) state.session = ev.session_id;
|
|
691
|
+
clearWorking();
|
|
692
|
+
finalizeRsn();
|
|
693
|
+
// 兜底:个别引擎只在 turn_end 带整轮正文(正文增量一条都没到时用它)
|
|
694
|
+
if (!state.cur && !state.agentRaw && ev.text) { ensureAgent(); state.agentRaw = ev.text; }
|
|
695
|
+
finalizeAgent(true);
|
|
696
|
+
stopPrevTool();
|
|
697
|
+
} else if (t === 'result') {
|
|
698
|
+
clearWorking();
|
|
699
|
+
finalizeRsn();
|
|
700
|
+
finalizeAgent(true);
|
|
701
|
+
stopPrevTool();
|
|
702
|
+
state.resultSeen = true;
|
|
703
|
+
pushItem(sepItem('完成 · ' + (ev.latency_ms || 0) + 'ms'));
|
|
704
|
+
} else if (t === 'error') {
|
|
705
|
+
clearWorking();
|
|
706
|
+
finalizeRsn();
|
|
707
|
+
finalizeAgent(true);
|
|
708
|
+
stopPrevTool();
|
|
709
|
+
pushItem(phItem('本轮失败:' + (ev.reason || ev.error || ''), 'err', 'failed'), true);
|
|
710
|
+
} else if (t === 'ask') {
|
|
711
|
+
finalizeRsn(); finalizeAgent(true); stopPrevTool();
|
|
712
|
+
/* 审批 / 提问卡。原先这里只弹一行 notice —— 而 ask 是 headless 下**唯一**能
|
|
713
|
+
喂答案的通道(core 的 --control answer),只弹一行等于把这条路掐断了:
|
|
714
|
+
引擎卡在等待批准,手机上却没有任何办法推进它。
|
|
715
|
+
结构与桌面版 approval() 对齐(同一份 events 契约),样式按 AA 的状态胶囊走。 */
|
|
716
|
+
approvalCard(ev);
|
|
717
|
+
} else if (t === 'interrupted' || t === 'stopped') {
|
|
718
|
+
clearWorking();
|
|
719
|
+
finalizeRsn();
|
|
720
|
+
finalizeAgent(true);
|
|
721
|
+
stopPrevTool();
|
|
722
|
+
pushItem(phItem(t === 'interrupted' ? '已中断' : '已停止', 'ok', null));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function clearThread() {
|
|
727
|
+
thread().innerHTML = '';
|
|
728
|
+
state.cur = null; state.agentRaw = '';
|
|
729
|
+
state.rsn = null; state.rsnRaw = '';
|
|
730
|
+
state.lastTool = null; state.working = null;
|
|
731
|
+
state.autoFollow = true;
|
|
732
|
+
copyBin.length = 0;
|
|
733
|
+
if (mdTimer) { clearTimeout(mdTimer); mdTimer = 0; }
|
|
734
|
+
nearBottom(); syncBottomBtn();
|
|
735
|
+
}
|
|
736
|
+
function syncBottomBtn() {
|
|
737
|
+
$('btnBottom').classList.toggle('tobottom--on', !state.autoFollow && thread().scrollHeight > thread().clientHeight + 80);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
/* 控制通道:打断 / 收工 / 回答案。op 的语义与 core 的 --control 同协议;
|
|
742
|
+
具体怎么送到 core 由 transport.control 决定(HTTP / IPC / CLI 都可能)。
|
|
743
|
+
⚠️ 没有 askId 就如实说,不要静默失败 —— 一个按下去没反应的按钮比报错更难查。
|
|
744
|
+
返回 Promise<boolean>,给审批卡判断「答没答上」用。 */
|
|
745
|
+
async function control(op, text) {
|
|
746
|
+
if (!state.askId) {
|
|
747
|
+
noticeItem('这一轮还没拿到轮次 id,稍等 open 事件回来再试');
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
var payload = { op: op, id: state.askId };
|
|
752
|
+
if (text != null) payload.text = text;
|
|
753
|
+
await transport.control(payload);
|
|
754
|
+
if (op !== 'answer') noticeItem(op === 'interrupt' ? '已请求打断' : '已请求收工');
|
|
755
|
+
return true;
|
|
756
|
+
} catch (e) {
|
|
757
|
+
noticeItem('控制失败:' + (e.message || e));
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/* ── 审批 / 提问卡 ──
|
|
763
|
+
与桌面版 approval() 同一套 events 契约(CLIENT-CONTRACT.md 的 ask 事件):
|
|
764
|
+
ev.ask.questions[] 每题带 options[],multi_select 为真时是多选。
|
|
765
|
+
答复走 control 的 op:'answer' —— 与 core 的 --control answer 同协议。 */
|
|
766
|
+
function approvalCard(ev) {
|
|
767
|
+
var ask = ev.ask || {};
|
|
768
|
+
var card = el('div', 'askcard');
|
|
769
|
+
var head = el('div', 'askcard__head');
|
|
770
|
+
head.appendChild(el('span', 'askcard__dot'));
|
|
771
|
+
head.appendChild(el('span', null, ask.kind === 'approval' ? '需要授权' : '需要确认'));
|
|
772
|
+
card.appendChild(head);
|
|
773
|
+
|
|
774
|
+
var qs = ask.questions || [];
|
|
775
|
+
if (!qs.length) {
|
|
776
|
+
card.appendChild(el('p', 'askcard__q', ev.text || '模型需要你的选择'));
|
|
777
|
+
var fb = el('button', 'askcard__btn askcard__btn--primary', '用输入框内容作答');
|
|
778
|
+
fb.onclick = function () { answerAsk(card, $('input').value.trim() || '是'); };
|
|
779
|
+
card.appendChild(fb);
|
|
780
|
+
pushItem(card, true);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
qs.forEach(function (q) {
|
|
785
|
+
card.appendChild(el('p', 'askcard__q', (q.header ? '【' + q.header + '】' : '') + (q.text || '')));
|
|
786
|
+
var opts = q.options || [];
|
|
787
|
+
var chosen = [];
|
|
788
|
+
var row = el('div', 'askcard__opts');
|
|
789
|
+
if (q.multi_select) {
|
|
790
|
+
opts.forEach(function (o) {
|
|
791
|
+
var b = el('button', 'askcard__btn', o.label || '');
|
|
792
|
+
if (o.description) b.title = o.description;
|
|
793
|
+
b.onclick = function () {
|
|
794
|
+
var i = chosen.indexOf(o.label);
|
|
795
|
+
if (i >= 0) { chosen.splice(i, 1); b.classList.remove('askcard__btn--primary'); }
|
|
796
|
+
else { chosen.push(o.label); b.classList.add('askcard__btn--primary'); }
|
|
797
|
+
};
|
|
798
|
+
row.appendChild(b);
|
|
799
|
+
});
|
|
800
|
+
var sub = el('button', 'askcard__btn askcard__btn--primary', '提交');
|
|
801
|
+
sub.onclick = function () { if (chosen.length) answerAsk(card, chosen.join('、')); };
|
|
802
|
+
row.appendChild(sub);
|
|
803
|
+
} else {
|
|
804
|
+
opts.forEach(function (o) {
|
|
805
|
+
var b = el('button', 'askcard__opt');
|
|
806
|
+
b.appendChild(el('b', null, o.label || ''));
|
|
807
|
+
if (o.description) b.appendChild(el('span', null, o.description));
|
|
808
|
+
b.onclick = function () { answerAsk(card, o.label || ''); };
|
|
809
|
+
row.appendChild(b);
|
|
810
|
+
});
|
|
811
|
+
if (!opts.length) {
|
|
812
|
+
var fb2 = el('button', 'askcard__btn askcard__btn--primary', '用输入框内容作答');
|
|
813
|
+
fb2.onclick = function () { answerAsk(card, $('input').value.trim() || '是'); };
|
|
814
|
+
row.appendChild(fb2);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
card.appendChild(row);
|
|
818
|
+
});
|
|
819
|
+
pushItem(card, true);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function answerAsk(card, text) {
|
|
823
|
+
control('answer', text).then(function (ok) {
|
|
824
|
+
if (!ok) return;
|
|
825
|
+
card.innerHTML = '';
|
|
826
|
+
var head = el('div', 'askcard__head');
|
|
827
|
+
head.appendChild(el('span', 'askcard__dot askcard__dot--ok'));
|
|
828
|
+
head.appendChild(el('span', null, '已答复:' + text));
|
|
829
|
+
card.appendChild(head);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/* ── 三项基础能力:引擎 / 模型 / 授权 ──
|
|
834
|
+
移动端原先只有引擎选择,模型与授权都是空的,而这两样恰恰是「能不能安全用」的
|
|
835
|
+
关键。所以这里补齐三件事,并把授权做成发送键旁的一枚胶囊:
|
|
836
|
+
四个档位要能一眼分清,full 更是必须看起来「危险」。 */
|
|
837
|
+
|
|
838
|
+
/* 四档授权模型,取值与文案对齐 internal/agent/permission.go(唯一真相),
|
|
839
|
+
顺序照 PermissionTiers() 的声明顺序。首项「沿用默认」= 空值:不传
|
|
840
|
+
--permission,由 core 用默认档 —— 刻意不给它预设某一档。 */
|
|
841
|
+
var PERMISSION_TIERS = [
|
|
842
|
+
{ v: '', label: '沿用默认', hint: '不传 --permission,由 core 用默认档' },
|
|
843
|
+
{ v: 'manual', label: '手动审批', hint: '每个写操作都要你点头(manual)' },
|
|
844
|
+
{ v: 'accept-edits', label: '自动接受编辑', hint: '编辑自动过,其余仍问(accept-edits)' },
|
|
845
|
+
{ v: 'auto', label: '自动审批', hint: '自动审批常规操作(auto)' },
|
|
846
|
+
{ v: 'full', label: '完全访问', hint: '等同 --dangerously-skip-permissions(full)' }
|
|
847
|
+
];
|
|
848
|
+
|
|
849
|
+
/* 当前引擎支不支持显式选档。core 只在**显式传过** --permission 时才校验
|
|
850
|
+
(internal/cli/ask.go::resolvePermissionTier),不支持就别传。
|
|
851
|
+
优先读 capabilities 里的 permission 字段,读不到再退回 core 当前接线的引擎名单。 */
|
|
852
|
+
function engineSupportsPermission(name) {
|
|
853
|
+
var e = (state.engines || []).filter(function (x) { return x.engine === name; })[0];
|
|
854
|
+
if (!e) return false;
|
|
855
|
+
if ((e.capabilities || []).indexOf('permission') >= 0) return true;
|
|
856
|
+
return ['claude', 'codebuddy', 'codebuddy-ai'].indexOf(name) >= 0;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function tierOf(v) {
|
|
860
|
+
for (var i = 0; i < PERMISSION_TIERS.length; i++) if (PERMISSION_TIERS[i].v === v) return PERMISSION_TIERS[i];
|
|
861
|
+
return PERMISSION_TIERS[0];
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function renderPermission() {
|
|
865
|
+
var b = $('btnPerm');
|
|
866
|
+
if (!b) return;
|
|
867
|
+
var t = tierOf(state.permission || '');
|
|
868
|
+
b.textContent = t.label;
|
|
869
|
+
b.setAttribute('data-tier', state.permission || '');
|
|
870
|
+
b.title = t.hint;
|
|
871
|
+
b.disabled = !engineSupportsPermission(state.engine);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* 引擎选完,模型与授权的可选范围都要跟着变(模型属于引擎,授权取决于引擎支不支持)。 */
|
|
875
|
+
function onEnginePicked(name) {
|
|
876
|
+
state.engine = name;
|
|
877
|
+
var models = (state.engines || []).filter(function (x) { return x.engine === name; })[0];
|
|
878
|
+
var list = (models && models.models) || [];
|
|
879
|
+
if (list.indexOf(state.model) < 0) state.model = '';
|
|
880
|
+
if (!engineSupportsPermission(name)) state.permission = '';
|
|
881
|
+
if ($('chatTitle').textContent.indexOf('新会话') === 0) $('chatTitle').textContent = '新会话 · ' + name;
|
|
882
|
+
renderPermission();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/* ══════════ 组件外壳:对话区 + 输入区 ══════════
|
|
886
|
+
下面这些是宿主原先自己写的那部分(会话详情里的发送、底部面板、接线),
|
|
887
|
+
抽到组件里由组件负责 —— 这样两端不必各写一遍。 */
|
|
888
|
+
|
|
889
|
+
function autoGrow() {
|
|
890
|
+
var t = ctx.input;
|
|
891
|
+
t.style.height = 'auto';
|
|
892
|
+
t.style.height = Math.min(t.scrollHeight, 200) + 'px';
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function send() {
|
|
896
|
+
var text = ctx.input.value.trim();
|
|
897
|
+
if (!text || state.busy) return;
|
|
898
|
+
if (!state.engine) { noticeItem('还没有可用引擎:请先选一个引擎'); return; }
|
|
899
|
+
userBubble(text, false);
|
|
900
|
+
ctx.input.value = ''; autoGrow();
|
|
901
|
+
state.busy = true; setSendMode('busy');
|
|
902
|
+
finalizeRsn(); finalizeAgent(true); stopPrevTool();
|
|
903
|
+
setWorking('Agent 运行中');
|
|
904
|
+
state.resultSeen = false;
|
|
905
|
+
state.askId = null;
|
|
906
|
+
if (opts.onBusy) { try { opts.onBusy(true); } catch (e) {} }
|
|
907
|
+
try {
|
|
908
|
+
var payload = { engine: state.engine, prompt: text };
|
|
909
|
+
if (state.model) payload.model = state.model;
|
|
910
|
+
/* 空值就不传:让 core 用它自己的默认档,不替用户预设(安全语义)。
|
|
911
|
+
引擎不支持显式选档时同样不传 —— core 只在显式传过时才校验。 */
|
|
912
|
+
if (state.permission && engineSupportsPermission(state.engine)) payload.permission = state.permission;
|
|
913
|
+
if (state.session) payload.session = state.session;
|
|
914
|
+
/* 一轮的完整生命周期交给 transport:
|
|
915
|
+
onOpen → 拿到轮次 id(控制通道的唯一落点)
|
|
916
|
+
onEvent → 逐条 core 事件
|
|
917
|
+
onEnd → 本轮收尾(含 core 退出码)
|
|
918
|
+
transport 负责保证 onEnd 一定会被调到一次 —— 否则界面会永远卡在 busy。 */
|
|
919
|
+
await new Promise(function (resolve) {
|
|
920
|
+
var settled = false;
|
|
921
|
+
var finish = function (code) {
|
|
922
|
+
if (settled) return;
|
|
923
|
+
settled = true;
|
|
924
|
+
if (code !== 0 && code != null) noticeItem('core 退出码 ' + code);
|
|
925
|
+
resolve();
|
|
926
|
+
};
|
|
927
|
+
try {
|
|
928
|
+
transport.ask(payload, {
|
|
929
|
+
onOpen: function (runID) { state.askId = runID || state.askId; },
|
|
930
|
+
onEvent: function (ev) { try { render(ev); } catch (e) {} },
|
|
931
|
+
onEnd: finish
|
|
932
|
+
});
|
|
933
|
+
} catch (e) {
|
|
934
|
+
noticeItem('发送失败:' + (e.message || e));
|
|
935
|
+
finish(null);
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
} catch (e) {
|
|
939
|
+
noticeItem('发送失败:' + (e.message || e));
|
|
940
|
+
} finally {
|
|
941
|
+
clearWorking(); finalizeRsn(); finalizeAgent(true); stopPrevTool();
|
|
942
|
+
if (!state.resultSeen) pushItem(sepItem('完成'));
|
|
943
|
+
state.busy = false; state.askId = null; setSendMode('idle');
|
|
944
|
+
if (opts.onBusy) { try { opts.onBusy(false); } catch (e) {} }
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function setSendMode(mode) {
|
|
949
|
+
var b = ctx.sendBtn;
|
|
950
|
+
/* ⚠️ busy 时**不能**把它 disabled:那样点它不会派发 click,
|
|
951
|
+
它就从「中断」退化成一枚按不动的图标。改指中断后,
|
|
952
|
+
重复发送由 send() 自己的 state.busy 守卫拦住,不需要靠 disabled 防。 */
|
|
953
|
+
b.disabled = false;
|
|
954
|
+
b.classList.toggle('sendbtn--stop', mode === 'busy');
|
|
955
|
+
b.innerHTML = '';
|
|
956
|
+
b.appendChild(svgIcon(mode === 'busy' ? 'i-clock' : 'i-up', 17));
|
|
957
|
+
b.setAttribute('aria-label', mode === 'busy' ? '中断' : '发送');
|
|
958
|
+
b.title = mode === 'busy' ? '中断这一轮' : '发送';
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/* ── 选择器浮层 ──
|
|
962
|
+
fixed + 挂 body,用视口坐标定位:不受任何祖先的 overflow / transform 影响。
|
|
963
|
+
这一条踩过坑 —— 菜单 append 进 flex 行里做 absolute 时宽高会塌成 0×0。 */
|
|
964
|
+
var openPick = null;
|
|
965
|
+
function closePick() {
|
|
966
|
+
if (openPick) { openPick.remove(); openPick = null; }
|
|
967
|
+
document.removeEventListener('mousedown', onPickDown, true);
|
|
968
|
+
document.removeEventListener('keydown', onPickKey, true);
|
|
969
|
+
window.removeEventListener('resize', closePick);
|
|
970
|
+
window.removeEventListener('scroll', closePick, true);
|
|
971
|
+
}
|
|
972
|
+
function onPickDown(e) { if (!(e.target.closest && e.target.closest('.pick'))) closePick(); }
|
|
973
|
+
function onPickKey(e) { if (e.key === 'Escape') { e.preventDefault(); closePick(); } }
|
|
974
|
+
function placePick(p, anchor) {
|
|
975
|
+
var a = anchor.getBoundingClientRect(), w = p.offsetWidth, h = p.offsetHeight;
|
|
976
|
+
var left = a.left;
|
|
977
|
+
if (left + w > window.innerWidth - 8) left = Math.max(8, window.innerWidth - w - 8);
|
|
978
|
+
var top = a.bottom + 6;
|
|
979
|
+
if (top + h > window.innerHeight - 8) top = Math.max(8, a.top - h - 6);
|
|
980
|
+
p.style.left = Math.round(left) + 'px';
|
|
981
|
+
p.style.top = Math.round(top) + 'px';
|
|
982
|
+
}
|
|
983
|
+
function showPick(anchor, build) {
|
|
984
|
+
if (openPick && openPick._anchor === anchor) { closePick(); return; }
|
|
985
|
+
closePick();
|
|
986
|
+
var p = el('div', 'pick pick--on');
|
|
987
|
+
p._anchor = anchor;
|
|
988
|
+
build(p);
|
|
989
|
+
document.body.appendChild(p);
|
|
990
|
+
placePick(p, anchor);
|
|
991
|
+
openPick = p;
|
|
992
|
+
document.addEventListener('mousedown', onPickDown, true);
|
|
993
|
+
document.addEventListener('keydown', onPickKey, true);
|
|
994
|
+
window.addEventListener('resize', closePick);
|
|
995
|
+
window.addEventListener('scroll', closePick, true);
|
|
996
|
+
}
|
|
997
|
+
function pickRow(label, desc, selected, onPick, danger) {
|
|
998
|
+
var b = el('button', 'pick__row' + (danger ? ' pick__danger' : ''));
|
|
999
|
+
b.type = 'button';
|
|
1000
|
+
b.setAttribute('aria-selected', String(!!selected));
|
|
1001
|
+
b.appendChild(el('span', 'pick__ck', selected ? '✓' : ''));
|
|
1002
|
+
var col = el('span');
|
|
1003
|
+
col.appendChild(el('span', 'pick__l', label));
|
|
1004
|
+
if (desc) col.appendChild(el('span', 'pick__d', desc));
|
|
1005
|
+
b.appendChild(col);
|
|
1006
|
+
b.onclick = function () { closePick(); onPick(); };
|
|
1007
|
+
return b;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/* 引擎 · 模型:一级列引擎,点开二级是该引擎的模型 */
|
|
1011
|
+
function openEnginePicker(anchor) {
|
|
1012
|
+
showPick(anchor, function (p) {
|
|
1013
|
+
if (!state.engines.length) {
|
|
1014
|
+
p.appendChild(el('div', 'pick__cap', '没有可用引擎'));
|
|
1015
|
+
p.appendChild(el('div', 'pick__row', 'magic-agent --engines 会给每个引擎的一键安装命令'));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
p.appendChild(el('div', 'pick__cap', '选择引擎'));
|
|
1019
|
+
state.engines.forEach(function (en) {
|
|
1020
|
+
var models = en.models || [];
|
|
1021
|
+
var row = pickRow(en.engine + (en.ok ? '' : '(未安装)'),
|
|
1022
|
+
en.ok ? (models.length + ' 个模型') : '未安装', state.engine === en.engine, function () {
|
|
1023
|
+
if (!en.ok) return;
|
|
1024
|
+
state.engine = en.engine;
|
|
1025
|
+
if (models.indexOf(state.model) < 0) state.model = '';
|
|
1026
|
+
if (!engineSupportsPermission(en.engine)) state.permission = '';
|
|
1027
|
+
syncPickers();
|
|
1028
|
+
});
|
|
1029
|
+
if (!en.ok) { row.disabled = true; p.appendChild(row); return; }
|
|
1030
|
+
p.appendChild(row);
|
|
1031
|
+
if (models.length) {
|
|
1032
|
+
p.appendChild(el('div', 'pick__cap', ' └ ' + en.engine + ' 的模型'));
|
|
1033
|
+
p.appendChild(pickRow('默认模型', '不传 -m,由引擎自己挑',
|
|
1034
|
+
state.engine === en.engine && !state.model, function () {
|
|
1035
|
+
state.engine = en.engine; state.model = ''; syncPickers();
|
|
1036
|
+
}));
|
|
1037
|
+
models.forEach(function (n) {
|
|
1038
|
+
p.appendChild(pickRow(n, '', state.engine === en.engine && state.model === n, function () {
|
|
1039
|
+
state.engine = en.engine; state.model = n; syncPickers();
|
|
1040
|
+
}));
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function openPermissionPicker(anchor) {
|
|
1048
|
+
showPick(anchor, function (p) {
|
|
1049
|
+
p.appendChild(el('div', 'pick__cap', '授权档位'));
|
|
1050
|
+
if (!engineSupportsPermission(state.engine)) {
|
|
1051
|
+
p.appendChild(el('div', 'pick__row', (state.engine || '当前引擎') + ' 不支持显式选档'));
|
|
1052
|
+
p.appendChild(el('div', 'pick__d', '仅 claude / codebuddy / codebuddy-ai,会沿用 core 默认'));
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
PERMISSION_TIERS.forEach(function (t) {
|
|
1056
|
+
p.appendChild(pickRow(t.label, t.hint, (state.permission || '') === t.v, function () {
|
|
1057
|
+
state.permission = t.v; syncPickers();
|
|
1058
|
+
}, t.v === 'full'));
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function loadHistory(sid, after) {
|
|
1064
|
+
if (!sid) return Promise.resolve(0);
|
|
1065
|
+
return Promise.resolve(transport.readSession(sid, after == null ? {} : { after: after }))
|
|
1066
|
+
.then(function (r) {
|
|
1067
|
+
var events = (r && r.events) || [];
|
|
1068
|
+
events.sort(function (a, b) { return Number(a.seq || 0) - Number(b.seq || 0); });
|
|
1069
|
+
events.forEach(function (ev) { try { render(ev); } catch (e) { /* 单条坏事件吞掉 */ } });
|
|
1070
|
+
var t = thread(); t.scrollTop = t.scrollHeight;
|
|
1071
|
+
return events.length;
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function syncPickers() {
|
|
1076
|
+
var casc = root.querySelector('.casc');
|
|
1077
|
+
if (casc) {
|
|
1078
|
+
casc.querySelector('.casc__p').textContent = state.engine || '选择引擎';
|
|
1079
|
+
casc.querySelector('.casc__s').textContent = state.model || '默认模型';
|
|
1080
|
+
}
|
|
1081
|
+
var chip = ctx.permBtn;
|
|
1082
|
+
if (chip) {
|
|
1083
|
+
var t = tierOf(state.permission || '');
|
|
1084
|
+
chip.querySelector('span').textContent = '授权 · ' + t.label;
|
|
1085
|
+
chip.setAttribute('data-tier', state.permission || '');
|
|
1086
|
+
chip.disabled = !engineSupportsPermission(state.engine);
|
|
1087
|
+
chip.title = engineSupportsPermission(state.engine) ? t.hint
|
|
1088
|
+
: (state.engine || '当前引擎') + ' 不支持显式选授权档位(仅 claude / codebuddy / codebuddy-ai),会沿用 core 默认';
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/* ══════════ 搭 DOM ══════════
|
|
1093
|
+
thread 同时是滚动容器与条目容器(保留段落里 pushItem / clearThread / scrollTop
|
|
1094
|
+
都指向它)—— 所以**不再套内层 wrapper**,宽屏居中靠 CSS 的对称 padding。 */
|
|
1095
|
+
var wrap = el('div', 'conv');
|
|
1096
|
+
var threadEl = el('div', 'conv__thread');
|
|
1097
|
+
ctx.thread = threadEl;
|
|
1098
|
+
wrap.appendChild(threadEl);
|
|
1099
|
+
/* 组件内不需要「回到底部」按钮,留个游离节点承接保留段落里的赋值,不让它报错 */
|
|
1100
|
+
ctx.bottomBtn = el('span', 'tobottom');
|
|
1101
|
+
|
|
1102
|
+
var form = el('form', 'composer');
|
|
1103
|
+
var cbox = el('div', 'composer__box');
|
|
1104
|
+
ctx.input = el('textarea'); ctx.input.rows = 1; ctx.input.placeholder = '回复 Agent';
|
|
1105
|
+
cbox.appendChild(ctx.input);
|
|
1106
|
+
var crow = el('div', 'composer__row');
|
|
1107
|
+
var cleft = el('div', 'composer__left');
|
|
1108
|
+
var casc = el('button', 'casc');
|
|
1109
|
+
casc.type = 'button';
|
|
1110
|
+
casc.appendChild(svgIcon('i-cpu', 14));
|
|
1111
|
+
casc.appendChild(el('span', 'casc__p', '选择引擎'));
|
|
1112
|
+
casc.appendChild(el('span', 'casc__sep', '·'));
|
|
1113
|
+
casc.appendChild(el('span', 'casc__s', '默认模型'));
|
|
1114
|
+
casc.appendChild(svgIcon('i-chev', 13));
|
|
1115
|
+
casc.className = 'casc';
|
|
1116
|
+
ctx.permBtn = el('button', 'permchip');
|
|
1117
|
+
ctx.permBtn.type = 'button';
|
|
1118
|
+
ctx.permBtn.appendChild(svgIcon('i-set', 14));
|
|
1119
|
+
ctx.permBtn.appendChild(el('span', null, '授权 · 沿用默认'));
|
|
1120
|
+
cleft.appendChild(casc); cleft.appendChild(ctx.permBtn);
|
|
1121
|
+
ctx.sendBtn = el('button', 'sendbtn');
|
|
1122
|
+
ctx.sendBtn.type = 'submit';
|
|
1123
|
+
crow.appendChild(cleft); crow.appendChild(ctx.sendBtn);
|
|
1124
|
+
cbox.appendChild(crow);
|
|
1125
|
+
form.appendChild(cbox);
|
|
1126
|
+
wrap.appendChild(form);
|
|
1127
|
+
root.appendChild(wrap);
|
|
1128
|
+
|
|
1129
|
+
/* 空态:一句话说明这个组件等什么,别让人对着一片空白猜 */
|
|
1130
|
+
var empty = el('div', 'conv__empty', '还没有事件。写一句任务,点右下角发送。');
|
|
1131
|
+
threadEl.appendChild(empty);
|
|
1132
|
+
|
|
1133
|
+
/* ══════════ 接线 ══════════ */
|
|
1134
|
+
threadEl.addEventListener('click', function (ev) {
|
|
1135
|
+
var t = ev.target && ev.target.closest ? ev.target.closest('[data-copy-idx]') : null;
|
|
1136
|
+
if (t) doCopy(copyBin[Number(t.getAttribute('data-copy-idx'))]);
|
|
1137
|
+
});
|
|
1138
|
+
threadEl.addEventListener('scroll', function () { state.autoFollow = nearBottom(); syncBottomBtn(); });
|
|
1139
|
+
ctx.input.addEventListener('input', autoGrow);
|
|
1140
|
+
form.addEventListener('submit', function (e) { e.preventDefault(); send(); });
|
|
1141
|
+
ctx.input.addEventListener('keydown', function (e) {
|
|
1142
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); }
|
|
1143
|
+
});
|
|
1144
|
+
casc.addEventListener('click', function (e) { e.stopPropagation(); openEnginePicker(casc); });
|
|
1145
|
+
ctx.permBtn.addEventListener('click', function (e) { e.stopPropagation(); openPermissionPicker(ctx.permBtn); });
|
|
1146
|
+
ctx.sendBtn.addEventListener('click', function (e) {
|
|
1147
|
+
if (!state.busy) return; // 空闲时照常 submit
|
|
1148
|
+
e.preventDefault();
|
|
1149
|
+
control('interrupt');
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
/* 首个事件进来就把空态拿走 */
|
|
1153
|
+
var _render = render;
|
|
1154
|
+
render = function (ev) {
|
|
1155
|
+
if (empty.parentNode) empty.remove();
|
|
1156
|
+
if (opts.onEvent) { try { opts.onEvent(ev); } catch (e) {} }
|
|
1157
|
+
return _render(ev);
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
function newSession() {
|
|
1161
|
+
state.session = null; state.askId = null;
|
|
1162
|
+
clearThread();
|
|
1163
|
+
threadEl.appendChild(empty);
|
|
1164
|
+
if (ctx.titleEl) ctx.titleEl.textContent = state.engine ? ('新会话 · ' + state.engine) : '新会话';
|
|
1165
|
+
setTimeout(function () { ctx.input.focus(); }, 0);
|
|
1166
|
+
}
|
|
1167
|
+
function setSession(sid, s) {
|
|
1168
|
+
state.session = sid || null;
|
|
1169
|
+
if (s && s.engine) { state.engine = s.engine; }
|
|
1170
|
+
clearThread();
|
|
1171
|
+
threadEl.appendChild(empty);
|
|
1172
|
+
if (ctx.titleEl) ctx.titleEl.textContent =
|
|
1173
|
+
(s && (s.prompt_head || s.title)) || (sid ? String(sid).slice(0, 8) : '会话');
|
|
1174
|
+
syncPickers();
|
|
1175
|
+
if (sid) {
|
|
1176
|
+
loadHistory(sid).catch(function (e) { noticeItem('历史加载失败:' + (e.message || e)); });
|
|
1177
|
+
}
|
|
1178
|
+
setTimeout(function () { ctx.input.focus(); }, 0);
|
|
1179
|
+
}
|
|
1180
|
+
function refreshEngines() {
|
|
1181
|
+
return Promise.resolve(transport.listEngines()).then(function (list) {
|
|
1182
|
+
state.engines = Array.isArray(list) ? list : [];
|
|
1183
|
+
if (!state.engine) {
|
|
1184
|
+
var first = state.engines.filter(function (x) { return x.ok; })[0];
|
|
1185
|
+
if (first) state.engine = first.engine;
|
|
1186
|
+
}
|
|
1187
|
+
syncPickers();
|
|
1188
|
+
return state.engines;
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
function destroy() {
|
|
1192
|
+
closePick();
|
|
1193
|
+
if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (state.engines.length) syncPickers();
|
|
1197
|
+
else refreshEngines().catch(function () { /* 拉不到就保持空清单,选择器会如实说没有可用引擎 */ });
|
|
1198
|
+
setSendMode('idle');
|
|
1199
|
+
syncPickers();
|
|
1200
|
+
if (state.session) setSession(state.session);
|
|
1201
|
+
|
|
1202
|
+
return {
|
|
1203
|
+
version: 1,
|
|
1204
|
+
el: root,
|
|
1205
|
+
state: state,
|
|
1206
|
+
send: send,
|
|
1207
|
+
interrupt: function () { return control('interrupt'); },
|
|
1208
|
+
stop: function () { return control('stop'); },
|
|
1209
|
+
answer: function (text) { return control('answer', text); },
|
|
1210
|
+
setEngine: function (n) { state.engine = n; syncPickers(); },
|
|
1211
|
+
setModel: function (n) { state.model = n; syncPickers(); },
|
|
1212
|
+
setPermission: function (v) { state.permission = v; syncPickers(); },
|
|
1213
|
+
setEngines: function (list) { state.engines = list || []; syncPickers(); },
|
|
1214
|
+
refreshEngines: refreshEngines,
|
|
1215
|
+
setSession: setSession,
|
|
1216
|
+
newSession: newSession,
|
|
1217
|
+
loadHistory: loadHistory,
|
|
1218
|
+
render: function (ev) { render(ev); },
|
|
1219
|
+
syncPickers: syncPickers,
|
|
1220
|
+
destroy: destroy
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
window.MagicConversation = { version: 1, create: create };
|
|
1225
|
+
})();
|