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
+10
View File
@@ -19,6 +19,7 @@ import {
snapshotPublicHtml,
verifyPageAccess,
verifySurveyDelivery,
verifyChildrenHobbyDietSurvey,
waitForAssistantGrowth,
waitForRunTerminal,
extractAssistantTexts,
@@ -200,6 +201,15 @@ async function runScenario(scenario, port) {
});
}
if (step.expect?.surveyDietUpdate) {
await verifyChildrenHobbyDietSurvey({
publishKey,
baseUrl,
reporter,
testInsert: step.expect.testInsert !== false,
});
}
const forbidReply = step.expect?.forbidReplyPatterns ?? [];
for (const pattern of forbidReply) {
if (pattern && reply.combined.includes(pattern)) {
+163
View File
@@ -449,6 +449,169 @@ export async function verifySurveyDelivery({
return true;
}
export const CHILDREN_HOBBY_DIET_SURVEY = {
userId: '32035858-9a20-425b-89da-c118ef0779aa',
username: 'john4',
surveyHtml: 'children-hobby-survey.html',
adminHtml: 'children-hobby-admin.html',
dataset: 'children_hobby_survey',
dietField: 'q4_diet_meals',
dietKeywords: ['饮食', '几顿', 'q4_diet_meals'],
};
async function readJsonIfExists(filePath) {
try {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
async function findPublicSurveyPolicy(publishKey, datasetName) {
const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
let entries = [];
try {
entries = await fs.readdir(policyDir);
} catch {
return null;
}
for (const name of entries.filter((item) => item.endsWith('.json'))) {
const policy = await readJsonIfExists(path.join(policyDir, name));
const dataset = policy?.datasets?.[datasetName];
if (policy?.accessMode === 'public' && dataset?.insert) {
return { pageId: policy.pageId, policy, fileName: name };
}
}
return null;
}
export async function verifyChildrenHobbyDietSurvey({
publishKey = CHILDREN_HOBBY_DIET_SURVEY.userId,
baseUrl = resolvePortalBase(Number(process.env.H5_PORT ?? 8081)),
reporter,
testInsert = true,
spec = CHILDREN_HOBBY_DIET_SURVEY,
} = {}) {
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
const publicDir = path.join(publishDir, 'public');
const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite');
let ok = true;
const surveyPath = path.join(publicDir, spec.surveyHtml);
const adminPath = path.join(publicDir, spec.adminHtml);
try {
const [surveyHtml, adminHtml] = await Promise.all([
fs.readFile(surveyPath, 'utf8'),
fs.readFile(adminPath, 'utf8'),
]);
reporter.pass('问卷 HTML 文件', spec.surveyHtml);
reporter.pass('后台 HTML 文件', spec.adminHtml);
const dietHits = spec.dietKeywords.filter((keyword) => surveyHtml.includes(keyword));
if (dietHits.length !== spec.dietKeywords.length) {
reporter.fail(
'问卷饮食字段',
`未命中: ${spec.dietKeywords.filter((keyword) => !surveyHtml.includes(keyword)).join(', ')}`,
);
ok = false;
} else {
reporter.pass('问卷饮食字段', dietHits.join(', '));
}
if (!surveyHtml.includes(`insertRow('${spec.dataset}'`) || !surveyHtml.includes(spec.dietField)) {
reporter.fail('问卷提交字段', `insertRow 未包含 ${spec.dietField}`);
ok = false;
} else {
reporter.pass('问卷提交字段', `${spec.dataset}.${spec.dietField}`);
}
if (!adminHtml.includes('每日饮食') || !adminHtml.includes(spec.dietField)) {
reporter.fail('后台饮食列', `未展示 ${spec.dietField}`);
ok = false;
} else {
reporter.pass('后台饮食列', spec.dietField);
}
if (!surveyHtml.includes('page-data-client.js') || !adminHtml.includes('page-data-client.js')) {
reporter.fail('Page Data 客户端', '问卷/后台未引用 page-data-client.js');
ok = false;
} else {
reporter.pass('Page Data 客户端', 'page-data-client.js');
}
} catch (error) {
reporter.fail('HTML 读取', error instanceof Error ? error.message : String(error));
ok = false;
}
const policyInfo = await findPublicSurveyPolicy(publishKey, spec.dataset);
if (!policyInfo) {
reporter.fail('公开问卷策略', '未找到 public insert policy');
ok = false;
} else {
reporter.pass('公开问卷策略', `${policyInfo.fileName} (${policyInfo.pageId})`);
const insertCols = policyInfo.policy?.datasets?.[spec.dataset]?.columns?.insert ?? [];
if (!insertCols.includes(spec.dietField)) {
reporter.fail('策略 insert 列', `${spec.dietField} 不在白名单: ${insertCols.join(', ')}`);
ok = false;
} else {
reporter.pass('策略 insert 列', insertCols.join(', '));
}
}
try {
await fs.stat(sqlitePath);
reporter.pass('私有 SQLite', '.mindspace/private-data.sqlite');
} catch {
reporter.fail('私有 SQLite', 'private-data.sqlite 不存在');
ok = false;
}
const surveyUrl = new URL(
`/MindSpace/${publishKey}/public/${spec.surveyHtml}`,
baseUrl,
).href;
const adminUrl = new URL(
`/MindSpace/${publishKey}/public/${spec.adminHtml}`,
baseUrl,
).href;
for (const [label, url] of [['问卷页 HTTP', surveyUrl], ['后台页 HTTP', adminUrl]]) {
const response = await fetch(url);
if (response.status !== 200) {
reporter.fail(label, `${response.status} ${url}`);
ok = false;
} else {
reporter.pass(label, url);
}
}
if (testInsert && policyInfo?.pageId) {
const payload = {
q1_age_group: '7-9岁',
q2_favorite_hobby: '阅读写作',
q3_development_area: 'automated verify insert',
[spec.dietField]: '4顿(3正餐+1加餐)',
};
const response = await fetch(
`${baseUrl}/api/public/pages/${policyInfo.pageId}/data/${spec.dataset}/rows`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
reporter.fail('公开 insert API', `${response.status} ${JSON.stringify(body)}`);
ok = false;
} else if (!body?.data?.row?.[spec.dietField]) {
reporter.fail('公开 insert API', `响应缺少 ${spec.dietField}`);
ok = false;
} else {
reporter.pass('公开 insert API', `${spec.dietField}=${body.data.row[spec.dietField]}`);
}
}
return ok;
}
export async function loadScenario(scenarioId) {
const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`);
const raw = await fs.readFile(scenarioPath, 'utf8');
@@ -0,0 +1,172 @@
#!/usr/bin/env node
/**
* 验证 john4 儿童爱好问卷「饮食偏好 / 每日几顿」增量改造链路(无需重新聊天)。
*
* Usage:
* node scripts/verify-children-hobby-diet-survey.mjs
* node scripts/verify-children-hobby-diet-survey.mjs --no-insert
* node scripts/verify-children-hobby-diet-survey.mjs --user-id <uuid> --port 8081
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import {
CHILDREN_HOBBY_DIET_SURVEY,
createReporter,
resolvePortalBase,
verifyChildrenHobbyDietSurvey,
} from './scenario-test-lib.mjs';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
import { createDbPool } from '../db.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
loadH5Environment(import.meta.dirname);
function parseArgs(argv) {
const options = {
userId: CHILDREN_HOBBY_DIET_SURVEY.userId,
port: Number(process.env.H5_PORT ?? 8081),
testInsert: true,
checkRuns: true,
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--no-insert') options.testInsert = false;
else if (arg === '--no-runs') options.checkRuns = false;
else if (arg === '--user-id' && argv[i + 1]) options.userId = argv[++i];
else if (arg === '--port' && argv[i + 1]) options.port = Number(argv[++i]);
else if (arg === '-h' || arg === '--help') {
console.log(`Usage: node scripts/verify-children-hobby-diet-survey.mjs [--no-insert] [--no-runs] [--user-id <uuid>] [--port <port>]`);
process.exit(0);
} else {
throw new Error(`未知参数: ${arg}`);
}
}
return options;
}
async function ensurePortalReady(baseUrl) {
const response = await fetch(`${baseUrl}/auth/status`);
if (!response.ok) {
throw new Error(`Portal 未就绪: ${baseUrl}/auth/status -> ${response.status}`);
}
}
function verifySqliteColumn(sqlitePath, tableName, columnName, reporter) {
try {
const output = execSync(
`sqlite3 ${JSON.stringify(sqlitePath)} "PRAGMA table_info(${tableName});"`,
{ encoding: 'utf8' },
);
if (!output.includes(`|${columnName}|`)) {
reporter.fail('SQLite 列', `${tableName}.${columnName} 不存在`);
return false;
}
reporter.pass('SQLite 列', `${tableName}.${columnName}`);
const datasetJson = execSync(
`sqlite3 ${JSON.stringify(sqlitePath)} "SELECT config_json FROM __page_data_datasets WHERE name='${CHILDREN_HOBBY_DIET_SURVEY.dataset}';"`,
{ encoding: 'utf8' },
).trim();
const config = JSON.parse(datasetJson);
const insertCols = config?.columns?.insert ?? [];
if (!insertCols.includes(columnName)) {
reporter.fail('dataset insert 列', `${columnName} 不在 ${insertCols.join(', ')}`);
return false;
}
reporter.pass('dataset insert 列', insertCols.join(', '));
return true;
} catch (error) {
reporter.fail('SQLite 检查', error instanceof Error ? error.message : String(error));
return false;
}
}
async function verifyAgentRunHealth(userId, reporter) {
if (!process.env.DATABASE_URL && !fs.existsSync(path.join(repoRoot, '.env'))) {
reporter.pass('Agent run 健康', '跳过(无 DATABASE_URL');
return true;
}
try {
for (const line of fs.readFileSync(path.join(repoRoot, '.env'), 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
const k = t.slice(0, eq).trim();
const v = t.slice(eq + 1).trim();
if (!process.env[k]) process.env[k] = v;
}
const pool = createDbPool();
const [runningRows] = await pool.query(
`SELECT id, agent_session_id, updated_at
FROM h5_agent_runs
WHERE user_id = ? AND status = 'running'
ORDER BY updated_at DESC
LIMIT 5`,
[userId],
);
await pool.end();
if (runningRows.length > 0) {
reporter.fail('Agent run 健康', `${runningRows.length} 条 running(可能前端仍在 loading`);
for (const row of runningRows) {
console.error(` - ${row.id} session=${row.agent_session_id}`);
}
return false;
}
reporter.pass('Agent run 健康', '无 orphan running');
return true;
} catch (error) {
reporter.fail('Agent run 健康', error instanceof Error ? error.message : String(error));
return false;
}
}
async function main() {
const options = parseArgs(process.argv);
const reporter = createReporter();
const baseUrl = resolvePortalBase(options.port);
const publishKey = options.userId;
const sqlitePath = path.join(
repoRoot,
PUBLISH_ROOT_DIR,
publishKey,
'.mindspace',
'private-data.sqlite',
);
console.log('==> 儿童爱好问卷 · 饮食偏好链路验证');
console.log(` Portal: ${baseUrl}`);
console.log(` 用户: ${publishKey}\n`);
await ensurePortalReady(baseUrl);
let ok = true;
ok = verifySqliteColumn(
sqlitePath,
CHILDREN_HOBBY_DIET_SURVEY.dataset,
CHILDREN_HOBBY_DIET_SURVEY.dietField,
reporter,
) && ok;
ok = await verifyChildrenHobbyDietSurvey({
publishKey,
baseUrl,
reporter,
testInsert: options.testInsert,
}) && ok;
if (options.checkRuns) {
ok = await verifyAgentRunHealth(publishKey, reporter) && ok;
}
const code = reporter.summary();
process.exit(code);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exit(1);
});
+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);
});