feat: chat uploads, vision turn isolation, and MindSpace agent improvements

Add chat file/image upload UX, attachment proxying, vision thumbnails, and per-turn image scoping so agents only use the current upload. Extend MindSpace asset context, billing token state, OA/scenario verify scripts, and related runtime config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-11 00:23:01 +08:00
parent e3063ea806
commit 32fb2cdeaf
51 changed files with 4588 additions and 186 deletions
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env node
/**
* Verify OA selected Excel analysis chain (john4 面条区域销售数据_示例.xlsx).
* Checks injected context + optional live agent run.
*/
import crypto from 'node:crypto';
import {
buildContextPrefix,
buildMindSpaceChatContext,
} from '../mindspace-chat-context.mjs';
import { USER_COOKIE } from '../user-auth.mjs';
import {
createReporter,
resolvePortalBase,
waitForAssistantGrowth,
waitForRunTerminal,
} from './scenario-test-lib.mjs';
const BASE = resolvePortalBase(process.env.PORTAL_PORT ?? process.env.PORT ?? 8081);
const ASSET_ID = process.env.OA_EXCEL_ASSET_ID ?? '597c0177-becd-4b89-be72-1b5fd3496f17';
const LIVE_RUN = process.env.VERIFY_OA_EXCEL_LIVE !== '0';
const TIMEOUT_MS = Number(process.env.VERIFY_OA_EXCEL_TIMEOUT_MS ?? 120000);
function buildUserAddressPrefix(username) {
return `[用户身份]
- 当前登录用户称呼:${username}
- 你是 TKMind 助手;与用户对话时用「${username}」称呼对方(如「${username},你好」),禁止把用户叫作 TKMind。
`;
}
async function login(reporter) {
const password = process.env.JOHN4_PASSWORD ?? '888888';
const response = await fetch(`${BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'john4', password }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok || !body?.authenticated) {
throw new Error(`登录失败: ${response.status} ${JSON.stringify(body)}`);
}
const setCookie = response.headers.getSetCookie?.() ?? [];
const cookieLine = setCookie.find((line) => line.startsWith(`${USER_COOKIE}=`))
?? response.headers.get('set-cookie');
const match = String(cookieLine ?? '').match(new RegExp(`${USER_COOKIE}=([^;]+)`));
const token = match?.[1] ? decodeURIComponent(match[1]) : null;
if (!token) throw new Error('未解析到会话 Cookie');
reporter.pass('登录', `john4 (${body.user?.id ?? 'unknown'})`);
return { cookie: `${USER_COOKIE}=${token}`, user: body.user };
}
async function apiFetch(cookie, path) {
const response = await fetch(`${BASE}${path}`, { headers: { Cookie: cookie } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`${path} ${response.status}: ${JSON.stringify(payload)}`);
}
return payload;
}
function countProbeNarration(text) {
const patterns = [
/Let me first look/i,
/没有 [`']?oa\/?[`']? 文件夹/i,
/文件不在当前工作区/i,
/确认工作目录/i,
/工作目录跳转/i,
/list_dir oa/i,
];
return patterns.filter((re) => re.test(text)).length;
}
async function main() {
const reporter = createReporter();
const { cookie, user } = await login(reporter);
const spacePayload = await apiFetch(cookie, '/api/mindspace/v1/space');
const space = spacePayload.data;
const oaCategory = space.categories.find((item) => item.code === 'oa');
if (!oaCategory) {
reporter.fail('OA 分类', 'space 中未找到 oa 分类');
process.exit(reporter.summary());
}
const assetsPayload = await apiFetch(cookie, '/api/mindspace/v1/assets?category=oa');
const assets = assetsPayload.data ?? [];
const asset = assets.find((item) => item.id === ASSET_ID)
?? assets.find((item) => /面条区域销售数据/.test(item.displayName ?? ''));
if (!asset) {
reporter.fail('Excel 资料', `未找到 asset ${ASSET_ID}`);
process.exit(reporter.summary());
}
reporter.pass('Excel 资料', `${asset.displayName} (${asset.id})`);
if (asset.workspaceRelativePath) {
reporter.pass('workspaceRelativePath', asset.workspaceRelativePath);
} else {
reporter.fail('workspaceRelativePath', 'API 未返回落盘路径');
}
const staleCategory = { ...oaCategory, itemCount: 0 };
const visibleAssets = [asset];
const context = buildMindSpaceChatContext({
space: { ...space, categories: space.categories.map((c) => (c.code === 'oa' ? staleCategory : c)) },
ownerUsername: user.username ?? 'john4',
selectedCategory: staleCategory,
selectedPageId: null,
pages: [],
assets: visibleAssets,
selectedAssets: [asset],
route: '/space?category=oa',
agentSessionId: 'verify-session',
h5ApiBase: BASE,
});
if (context.category?.itemCount === 1) {
reporter.pass('itemCount 兜底', 'stale 0 → effective 1');
} else {
reporter.fail('itemCount 兜底', `expected 1 got ${context.category?.itemCount}`);
}
const prefix = buildContextPrefix(context);
const prefixChecks = [
...(asset.workspaceRelativePath
? [
['已落盘路径', /已落盘路径/],
['read_file 路径', new RegExp(`read_file ${asset.workspaceRelativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)],
['禁止 list_dir', /禁止.*list_dir/],
]
: [
['优先 list_dir oa', /优先.*list_dir oa/],
]),
['数据表汇总校验', /数据表汇总校验/],
['共 1 项', /共 1 项/],
];
for (const [label, pattern] of prefixChecks) {
if (pattern.test(prefix)) reporter.pass(`上下文 · ${label}`, 'ok');
else reporter.fail(`上下文 · ${label}`, 'prefix 缺少预期片段');
}
if (!LIVE_RUN) {
console.log('\n跳过 live runVERIFY_OA_EXCEL_LIVE=0');
process.exit(reporter.summary());
}
const userText = '帮我分析一下这个数据吧';
const agentText = `${buildUserAddressPrefix(user.username ?? 'john4')}${prefix}${userText}`;
const requestId = crypto.randomUUID();
const runResponse = await fetch(`${BASE}/api/agent/runs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: cookie,
},
body: JSON.stringify({
request_id: requestId,
user_message: {
id: crypto.randomUUID(),
role: 'user',
content: [{ type: 'text', text: agentText }],
metadata: { userVisible: true, displayText: userText },
},
selected_asset_ids: [asset.id],
}),
});
const runPayload = await runResponse.json().catch(() => ({}));
if (!runResponse.ok) {
reporter.fail('创建 run', `${runResponse.status} ${JSON.stringify(runPayload)}`);
process.exit(reporter.summary());
}
const run = runPayload.run ?? runPayload;
const runId = run.id;
let resolvedSessionId = run.sessionId ?? run.agent_session_id ?? run.agentSessionId ?? null;
reporter.pass('创建 run', `${runId.slice(0, 8)}${resolvedSessionId ? ` session=${resolvedSessionId}` : ''}`);
const terminal = await waitForRunTerminal(BASE, cookie, runId, TIMEOUT_MS);
resolvedSessionId = resolvedSessionId
?? terminal.agent_session_id
?? terminal.agentSessionId
?? terminal.sessionId
?? null;
if (!resolvedSessionId) {
reporter.fail('sessionId', 'run 完成后仍无法解析 sessionId');
process.exit(reporter.summary());
}
reporter.pass('run 终态', `${terminal.status} · session=${resolvedSessionId}`);
const growth = await waitForAssistantGrowth(BASE, cookie, resolvedSessionId, {
previousCount: 0,
minChars: 80,
timeoutMs: 5000,
});
if (!growth?.combined) {
reporter.fail('assistant 回复', '未拿到可见回复');
process.exit(reporter.summary());
}
const combined = growth.combined;
reporter.pass('assistant 回复长度', `${combined.length} chars / ${growth.count} msgs`);
const probeHits = countProbeNarration(combined);
if (probeHits === 0) {
reporter.pass('路径探测旁白', '未检测到典型 probe 句式');
} else {
reporter.fail('路径探测旁白', `仍检测到 ${probeHits} 类 probe 句式`);
}
if (/4[,.]?872[,.]?000|4872000|487\.2\s*万/.test(combined)) {
reporter.pass('总销售额', '包含正确总计 ~487.2万');
} else if (/4[,.]?470[,.]?500|4470500/.test(combined)) {
reporter.fail('总销售额', '仍输出错误的 447.05 万');
} else {
reporter.fail('总销售额', '回复中未识别到明确总计(需人工查看)');
}
if (/54[,.]?200/.test(combined) && !/55[,.]?400|55400/.test(combined)) {
reporter.fail('总销量', '仍输出错误的 54200 箱');
} else if (/55[,.]?400|55400/.test(combined)) {
reporter.pass('总销量', '包含正确总计 55400 箱');
}
console.log('\n--- assistant 摘要 ---');
console.log(combined.slice(0, 1200));
process.exit(reporter.summary());
}
main().catch((err) => {
console.error(err);
process.exit(1);
});