|
@@ -44,8 +44,11 @@
|
|
|
}
|
|
}
|
|
|
.msg.assistant .bubble {
|
|
.msg.assistant .bubble {
|
|
|
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
|
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
|
|
- padding: 10px 14px; white-space: pre-wrap; word-break: break-word;
|
|
|
|
|
|
|
+ padding: 10px 14px; word-break: break-word;
|
|
|
}
|
|
}
|
|
|
|
|
+ /* 正文文本段:每段对应「两次工具调用之间」模型说的话 */
|
|
|
|
|
+ .segment { white-space: pre-wrap; }
|
|
|
|
|
+ .segment + .segment { margin-top: 8px; }
|
|
|
.card {
|
|
.card {
|
|
|
border: 1px solid var(--line); background: var(--panel); border-radius: 8px;
|
|
border: 1px solid var(--line); background: var(--panel); border-radius: 8px;
|
|
|
padding: 8px 12px; margin: 6px 0; font-size: 13px;
|
|
padding: 8px 12px; margin: 6px 0; font-size: 13px;
|
|
@@ -106,7 +109,7 @@ const USER = 'default'; // 2.0 无鉴权:这里自报身份
|
|
|
let sessionId = null;
|
|
let sessionId = null;
|
|
|
let controller = null;
|
|
let controller = null;
|
|
|
let streaming = false;
|
|
let streaming = false;
|
|
|
-let pendingApproval = null;
|
|
|
|
|
|
|
+let resuming = false;
|
|
|
|
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
const el = (id) => document.getElementById(id);
|
|
|
const log = el('log');
|
|
const log = el('log');
|
|
@@ -138,7 +141,27 @@ function newAssistant() {
|
|
|
const b = document.createElement('div');
|
|
const b = document.createElement('div');
|
|
|
b.className = 'bubble';
|
|
b.className = 'bubble';
|
|
|
node.appendChild(b);
|
|
node.appendChild(b);
|
|
|
- return { node, bubble: b, tools: {}, thinking: null };
|
|
|
|
|
|
|
+ return { node, bubble: b, tools: {}, thinking: null, segment: null };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 正文按「文本段」切分,与工具卡片按时间顺序交替排列。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么不能直接 `bubble.textContent += token`:读 textContent 会连子元素
|
|
|
|
|
+// (工具卡片)的文本一起读出来,赋值又会覆盖 bubble 的全部子节点——结果是
|
|
|
|
|
+// 卡片被抹平成一坨纯文本,且每次 append 都重新拼接全文(O(N²))。
|
|
|
|
|
+function appendToken(ui, text) {
|
|
|
|
|
+ if (!ui.segment) {
|
|
|
|
|
+ ui.segment = document.createElement('div');
|
|
|
|
|
+ ui.segment.className = 'segment';
|
|
|
|
|
+ ui.bubble.appendChild(ui.segment);
|
|
|
|
|
+ }
|
|
|
|
|
+ ui.segment.textContent += text;
|
|
|
|
|
+ scrollDown();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 插入卡片时结束当前文本段,下一批 token 会另起一段,从而保留先后顺序
|
|
|
|
|
+function breakSegment(ui) {
|
|
|
|
|
+ ui.segment = null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function addCard(bubble, cls, title, body) {
|
|
function addCard(bubble, cls, title, body) {
|
|
@@ -158,6 +181,11 @@ function addCard(bubble, cls, title, body) {
|
|
|
return card;
|
|
return card;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function addCardFor(ui, cls, title, body) {
|
|
|
|
|
+ breakSegment(ui);
|
|
|
|
|
+ return addCard(ui.bubble, cls, title, body);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ---------- 发送 ----------
|
|
// ---------- 发送 ----------
|
|
|
async function send() {
|
|
async function send() {
|
|
|
const input = el('input');
|
|
const input = el('input');
|
|
@@ -251,6 +279,7 @@ function handle(ev, ui, citations) {
|
|
|
break;
|
|
break;
|
|
|
case 'thinking_token':
|
|
case 'thinking_token':
|
|
|
if (!ui.thinking) {
|
|
if (!ui.thinking) {
|
|
|
|
|
+ breakSegment(ui);
|
|
|
ui.thinking = document.createElement('details');
|
|
ui.thinking = document.createElement('details');
|
|
|
const s = document.createElement('summary');
|
|
const s = document.createElement('summary');
|
|
|
s.textContent = '思考过程';
|
|
s.textContent = '思考过程';
|
|
@@ -268,20 +297,22 @@ function handle(ev, ui, citations) {
|
|
|
setStatus(ev.message || '执行中...');
|
|
setStatus(ev.message || '执行中...');
|
|
|
if (ev.tools && ev.tools.length) {
|
|
if (ev.tools && ev.tools.length) {
|
|
|
const i = ev.tools.length - 1;
|
|
const i = ev.tools.length - 1;
|
|
|
- ui.tools[ev.tools[i]] = addCard(ui.bubble, 'pending', `调用 ${ev.cn_tools ? ev.cn_tools[i] : ev.tools[i]}`, '');
|
|
|
|
|
|
|
+ ui.tools[ev.tools[i]] = addCardFor(ui, 'pending', `调用 ${ev.cn_tools ? ev.cn_tools[i] : ev.tools[i]}`, '');
|
|
|
}
|
|
}
|
|
|
break;
|
|
break;
|
|
|
case 'tool_call':
|
|
case 'tool_call':
|
|
|
- ui.tools[ev.tool] = addCard(ui.bubble, 'pending', `工具 ${ev.cn_tool || ev.tool}`, JSON.stringify(ev.args, null, 1));
|
|
|
|
|
|
|
+ // 按 tool_call_id 索引:同一工具被调多次时按名字索引会把结果挂错卡
|
|
|
|
|
+ ui.tools[ev.tool_call_id || ev.tool] = addCardFor(ui, 'pending', `工具 ${ev.cn_tool || ev.tool}`, JSON.stringify(ev.args, null, 1));
|
|
|
break;
|
|
break;
|
|
|
case 'tool_result': {
|
|
case 'tool_result': {
|
|
|
- const card = ui.tools[ev.tool];
|
|
|
|
|
|
|
+ const key = ev.tool_call_id || ev.tool;
|
|
|
|
|
+ const card = ui.tools[key] || ui.tools[ev.tool];
|
|
|
const body = typeof ev.content === 'string' ? ev.content : JSON.stringify(ev.content, null, 1);
|
|
const body = typeof ev.content === 'string' ? ev.content : JSON.stringify(ev.content, null, 1);
|
|
|
if (card) {
|
|
if (card) {
|
|
|
card.className = 'card ' + (ev.error ? 'error' : 'done');
|
|
card.className = 'card ' + (ev.error ? 'error' : 'done');
|
|
|
const pre = document.createElement('pre'); pre.textContent = body; card.appendChild(pre);
|
|
const pre = document.createElement('pre'); pre.textContent = body; card.appendChild(pre);
|
|
|
} else {
|
|
} else {
|
|
|
- addCard(ui.bubble, ev.error ? 'error' : 'done', `结果 ${ev.cn_tool || ev.tool}`, body);
|
|
|
|
|
|
|
+ addCardFor(ui, ev.error ? 'error' : 'done', `结果 ${ev.cn_tool || ev.tool}`, body);
|
|
|
}
|
|
}
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
@@ -289,8 +320,7 @@ function handle(ev, ui, citations) {
|
|
|
renderTodos(ui, ev.todos || []);
|
|
renderTodos(ui, ev.todos || []);
|
|
|
break;
|
|
break;
|
|
|
case 'token':
|
|
case 'token':
|
|
|
- ui.bubble.textContent += ev.content || '';
|
|
|
|
|
- scrollDown();
|
|
|
|
|
|
|
+ appendToken(ui, ev.content || '');
|
|
|
break;
|
|
break;
|
|
|
case 'citations':
|
|
case 'citations':
|
|
|
citations.length = 0;
|
|
citations.length = 0;
|
|
@@ -306,11 +336,11 @@ function handle(ev, ui, citations) {
|
|
|
break;
|
|
break;
|
|
|
case 'error':
|
|
case 'error':
|
|
|
setStatus('出错');
|
|
setStatus('出错');
|
|
|
- addCard(ui.bubble, 'error', `错误 [${ev.code || '-'}]`, ev.message || '');
|
|
|
|
|
|
|
+ addCardFor(ui, 'error', `错误 [${ev.code || '-'}]`, ev.message || '');
|
|
|
break;
|
|
break;
|
|
|
case 'done':
|
|
case 'done':
|
|
|
if (ev.usage) {
|
|
if (ev.usage) {
|
|
|
- addCard(ui.bubble, '', '用量',
|
|
|
|
|
|
|
+ addCardFor(ui, '', '用量',
|
|
|
`输入 ${ev.usage.input_tokens} / 输出 ${ev.usage.output_tokens} / 合计 ${ev.usage.total_tokens}(${ev.usage.usage_source})`);
|
|
`输入 ${ev.usage.input_tokens} / 输出 ${ev.usage.output_tokens} / 合计 ${ev.usage.total_tokens}(${ev.usage.usage_source})`);
|
|
|
}
|
|
}
|
|
|
break;
|
|
break;
|
|
@@ -318,6 +348,7 @@ function handle(ev, ui, citations) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderTodos(ui, todos) {
|
|
function renderTodos(ui, todos) {
|
|
|
|
|
+ breakSegment(ui);
|
|
|
let card = ui.node.querySelector('.todos');
|
|
let card = ui.node.querySelector('.todos');
|
|
|
if (!card) {
|
|
if (!card) {
|
|
|
card = document.createElement('div');
|
|
card = document.createElement('div');
|
|
@@ -330,6 +361,7 @@ function renderTodos(ui, todos) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderCitations(ui, items, modelOnly) {
|
|
function renderCitations(ui, items, modelOnly) {
|
|
|
|
|
+ breakSegment(ui);
|
|
|
const box = document.createElement('div');
|
|
const box = document.createElement('div');
|
|
|
box.className = 'card cite';
|
|
box.className = 'card cite';
|
|
|
if (!items.length) {
|
|
if (!items.length) {
|
|
@@ -350,10 +382,10 @@ function renderCitations(ui, items, modelOnly) {
|
|
|
function renderInterrupt(ui, ev) {
|
|
function renderInterrupt(ui, ev) {
|
|
|
const ta = ev.tool_approval;
|
|
const ta = ev.tool_approval;
|
|
|
if (!ta) {
|
|
if (!ta) {
|
|
|
- addCard(ui.bubble, 'approval', '智能体暂停', ev.message || '等待输入');
|
|
|
|
|
|
|
+ addCardFor(ui, 'approval', '智能体暂停', ev.message || '等待输入');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- const card = addCard(ui.bubble, 'approval',
|
|
|
|
|
|
|
+ const card = addCardFor(ui, 'approval',
|
|
|
`需确认:${ta.tool}`,
|
|
`需确认:${ta.tool}`,
|
|
|
`${ta.reason || ''}\n${ta.side_effect || ''}\n\n参数:${JSON.stringify(ta.args, null, 1)}`);
|
|
`${ta.reason || ''}\n${ta.side_effect || ''}\n\n参数:${JSON.stringify(ta.args, null, 1)}`);
|
|
|
const row = document.createElement('div');
|
|
const row = document.createElement('div');
|
|
@@ -367,9 +399,16 @@ function renderInterrupt(ui, ev) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function resume(extra, ui) {
|
|
async function resume(extra, ui) {
|
|
|
- if (streaming) return;
|
|
|
|
|
|
|
+ // 双重保险:按钮点击后已经被 row.remove() 摘掉,这里再挡一次重复调用。
|
|
|
|
|
+ // 恢复请求重复发出会让图在同一 thread 上被 resume 两次,第二次必然失败。
|
|
|
|
|
+ if (streaming || resuming) return;
|
|
|
|
|
+ resuming = true;
|
|
|
setStatus('恢复中...');
|
|
setStatus('恢复中...');
|
|
|
- await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true);
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ resuming = false;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function escapeHtml(s) {
|
|
function escapeHtml(s) {
|