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:
@@ -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 run(VERIFY_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);
|
||||
});
|
||||
Reference in New Issue
Block a user