fix: 消除会话列表解析阻塞并按需加载工具详情

This commit is contained in:
shiyue
2026-10-19 10:58:22 +08:00
parent fb2c3360c9
commit 99cc807f4b
35 changed files with 1468 additions and 214 deletions

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
function extractFunction(name) {
const declaration = new RegExp(`^ function ${name}\\(`, 'm');
const start = source.search(declaration);
assert(start >= 0, `缺少前端函数 ${name}`);
const remaining = source.slice(start + 1);
const next = remaining.search(/^ (?:async )?function \w+\(/m);
assert(next >= 0, `无法确定前端函数 ${name} 的边界`);
return source.slice(start, start + 1 + next);
}
class Element {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.textContent = '';
this.attributes = {};
this.listeners = {};
}
appendChild(child) { this.children.push(child); return child; }
setAttribute(name, value) { this.attributes[name] = value; }
addEventListener(name, listener) { this.listeners[name] = listener; }
insertAdjacentHTML(position, html) { this.html = html; }
}
function createHarness() {
// 解析、分支选择和直链图片 DOM 均执行生产函数,只替换附件加载及浏览器环境。
return new Function('Element', `
const frames = [], rendered = [], hydrated = [];
const document = { createElement: (tagName) => new Element(tagName) };
function requestAnimationFrame(callback) { frames.push(callback); }
function renderAttachmentPreviews(attachments) {
rendered.push(attachments);
return '<div class="attachment-previews"></div>';
}
function hydrateAttachmentPreviews(wrapper, attachments) { hydrated.push({ wrapper, attachments }); }
${[
'parseMaybeJsonObject', 'parseCcwebDisplayImageResult',
'createCcwebDisplayImageToolContent', 'createAssistantImageElement',
].map(extractFunction).join('\n')}
return {
parse: parseCcwebDisplayImageResult,
render: createCcwebDisplayImageToolContent,
rendered,
hydrated,
flushFrames() { while (frames.length) frames.shift()(); },
};
`)(Element);
}
function run() {
const harness = createHarness();
const attachment = { id: 'generated-image', filename: 'generated.png', storageState: 'ready' };
const success = {
ok: true,
image: { url: 'https://example.test/generated.png', title: '生成的图片' },
attachment,
};
const textContent = [{ type: 'text', text: JSON.stringify(success) }];
// 与故障会话存档相同的 CallToolResult 结构,包含重复的业务 JSON 和 _meta。
const historicalEnvelope = { content: textContent, structuredContent: success, _meta: null };
const successCases = [
['直接业务对象', success],
['直接业务 JSON', JSON.stringify(success)],
['历史 MCP 对象', historicalEnvelope],
['历史 MCP JSON', JSON.stringify(historicalEnvelope)],
['仅 structuredContent', { structuredContent: success }],
['structuredContent 为 JSON', { structuredContent: JSON.stringify(success) }],
['仅 content 文本', { content: textContent }],
['content 文本包装的 JSON', JSON.stringify({ content: textContent })],
['缺失结构化结果', { structuredContent: null, content: textContent }],
['结构化结果无效时回退文本', { structuredContent: '无效结果', content: textContent }],
['跳过非业务文本及其他块', { content: [
{ type: 'image', data: 'irrelevant' }, { type: 'text', text: '处理中' },
{ type: 'text', text: '{invalid json' }, ...textContent,
] }],
];
for (const [label, result] of successCases) {
assert.deepEqual(harness.parse(result), success, `${label}应解析为业务结果`);
const renderedCount = harness.rendered.length;
const node = harness.render({ result, done: true });
assert.equal(node.textContent, '', `${label}不能误报图片失败`);
assert.equal(harness.rendered.length, renderedCount + 1, `${label}应进入附件预览分支`);
assert.deepEqual(harness.rendered.at(-1), [attachment]);
harness.flushFrames();
assert.equal(harness.hydrated.at(-1).wrapper, node);
assert.deepEqual(harness.hydrated.at(-1).attachments, [attachment]);
}
const failure = { ok: false, message: '图片文件不存在' };
for (const result of [failure, JSON.stringify(failure),
{ structuredContent: failure, content: textContent },
{ content: [{ type: 'text', text: JSON.stringify(failure) }] }]) {
assert.deepEqual(harness.parse(result), failure, '应保留明确的业务失败');
assert.equal(harness.render({ result, done: true }).textContent, failure.message);
}
const transportErrors = [
[{ isError: true, content: [{ type: 'text', text: 'MCP 图片工具读取失败' }] }, 'MCP 图片工具读取失败'],
[{ isError: true, structuredContent: failure }, failure.message],
[{ isError: true, message: '工具调用失败', structuredContent: success }, '工具调用失败'],
[{ isError: true, structuredContent: success, content: textContent }, JSON.stringify(success)],
[{ isError: true }, '图片渲染失败'],
];
for (const [result, message] of transportErrors) {
assert.equal(harness.parse(result).ok, false, 'MCP 错误不能被成功内容覆盖');
assert.equal(harness.render({ result: JSON.stringify(result), done: true }).textContent, message);
}
const malformed = [null, undefined, '', '{invalid', 'null', 'false', '42',
'"text"', [], JSON.stringify([success]), {}, { ok: 'true', attachment },
{ content: 'invalid' }, { content: [{ type: 'text', text: 'invalid' }] },
{ structuredContent: { structuredContent: success } }];
const cyclic = {};
cyclic.structuredContent = cyclic;
malformed.push(cyclic);
for (const result of malformed) {
assert.equal(harness.parse(result), null, '畸形或循环结果应安全返回空值');
assert.equal(harness.render({ result, done: true }).textContent, '图片渲染失败');
}
assert.equal(harness.render({ done: false }).textContent, '正在准备图片…', '未完成调用应显示等待状态');
const inlineResult = { ok: true, image: { url: 'https://example.test/inline.png', title: '直链图片' } };
for (const result of [inlineResult, { structuredContent: inlineResult },
{ content: [{ type: 'text', text: JSON.stringify(inlineResult) }] }]) {
const node = harness.render({ result: JSON.stringify(result), done: true });
const figure = node.children[0];
assert.equal(figure.tagName, 'figure', '直链结果应进入图片分支');
assert.equal(figure.children[0].children[0].src, inlineResult.image.url);
assert.equal(figure.children[0].children[0].alt, inlineResult.image.title);
}
for (const image of [{}, { url: 'javascript:alert(1)' }]) {
assert.equal(harness.render({ result: { ok: true, image }, done: true }).textContent,
'图片来源不可用', '无可用图片来源时应保留兜底提示');
}
console.log('ccweb_display_image 解析及渲染回归通过');
}
if (require.main === module) run();
module.exports = { createHarness, run };