fix: 消除会话列表解析阻塞并按需加载工具详情
This commit is contained in:
150
scripts/ccweb-display-image-unit.js
Normal file
150
scripts/ccweb-display-image-unit.js
Normal 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 };
|
||||
Reference in New Issue
Block a user