diff --git a/agent-run-deliverable-check.mjs b/agent-run-deliverable-check.mjs index f3e9624..dfe5685 100644 --- a/agent-run-deliverable-check.mjs +++ b/agent-run-deliverable-check.mjs @@ -2,8 +2,12 @@ export const RECOVERABLE_FINISH_ERROR_CODES = new Set([ 'AGENT_RUN_TIMEOUT', 'SESSION_REPLY_TIMEOUT', 'SESSION_REPLY_INCOMPLETE', + 'AGENT_RUN_STALE_RECOVERY', ]); +export const SESSION_FINISHED_STALE_GRACE_MS = 90 * 1000; +export const WORKSPACE_DELIVERABLE_LOOKBACK_MS = 60 * 1000; + export function isRecoverableFinishError(error) { const code = String(error?.code ?? '').trim(); return RECOVERABLE_FINISH_ERROR_CODES.has(code); @@ -15,6 +19,38 @@ export function hasRecoverableSessionDeliverables(summary) { return pageCount > 0 || publicationCount > 0; } +function mapDeliverableRows(rows) { + const pages = (rows ?? []).map((row) => ({ + pageId: String(row.page_id ?? '').trim(), + title: String(row.title ?? '').trim(), + publicationId: row.publication_id ? String(row.publication_id) : null, + publicationStatus: row.publication_status ? String(row.publication_status) : null, + publicUrl: row.public_url ? String(row.public_url) : null, + })).filter((row) => row.pageId); + const publicationCount = pages.filter((row) => row.publicationId).length; + return { + pageCount: pages.length, + publicationCount, + pages, + }; +} + +export function mergeDeliverableSummaries(...summaries) { + const pagesById = new Map(); + for (const summary of summaries) { + for (const page of summary?.pages ?? []) { + if (!page?.pageId) continue; + pagesById.set(page.pageId, page); + } + } + const pages = [...pagesById.values()]; + return { + pageCount: pages.length, + publicationCount: pages.filter((row) => row.publicationId).length, + pages, + }; +} + export async function detectSessionDeliverables(pool, userId, sessionId) { if (!pool?.query || !userId || !sessionId) { return { pageCount: 0, publicationCount: 0, pages: [] }; @@ -36,18 +72,92 @@ export async function detectSessionDeliverables(pool, userId, sessionId) { ORDER BY p.created_at ASC`, [userId, sessionId], ); - const pages = (rows ?? []).map((row) => ({ - pageId: String(row.page_id ?? '').trim(), - title: String(row.title ?? '').trim(), - publicationId: row.publication_id ? String(row.publication_id) : null, - publicationStatus: row.publication_status ? String(row.publication_status) : null, - publicUrl: row.public_url ? String(row.public_url) : null, - })).filter((row) => row.pageId); - const publicationCount = pages.filter((row) => row.publicationId).length; + return mapDeliverableRows(rows); +} + +export async function detectWorkspaceSyncedDeliverables(pool, userId, { sinceMs = null } = {}) { + if (!pool?.query || !userId) { + return { pageCount: 0, publicationCount: 0, pages: [] }; + } + const sinceClause = sinceMs != null ? 'AND p.updated_at >= ?' : ''; + const params = sinceMs != null ? [userId, sinceMs] : [userId]; + const [rows] = await pool.query( + `SELECT p.id AS page_id, + p.title, + pr.id AS publication_id, + pr.status AS publication_status, + pr.public_url + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + LEFT JOIN h5_publish_records pr + ON pr.page_id = p.id + AND pr.user_id = p.user_id + AND pr.status = 'online' + WHERE p.user_id = ? + AND p.status <> 'deleted' + ${sinceClause} + AND ( + p.workspace_relative_path LIKE 'public/%.html' + OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) LIKE 'public/%.html' + ) + AND ( + JSON_EXTRACT(pv.source_snapshot_json, '$.auto_synced') = true + OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.content_mode')) = 'static_html' + ) + ORDER BY p.updated_at DESC`, + params, + ); + return mapDeliverableRows(rows); +} + +export async function prepareAndDetectSessionDeliverables({ + pool, + userId, + sessionId, + runStartedAtMs = null, + prepareDeliverables = null, +} = {}) { + if (typeof prepareDeliverables === 'function') { + await prepareDeliverables({ userId, sessionId }).catch(() => {}); + } + const sinceMs = runStartedAtMs == null + ? null + : Math.max(0, Number(runStartedAtMs) - WORKSPACE_DELIVERABLE_LOOKBACK_MS); + const bySession = await detectSessionDeliverables(pool, userId, sessionId); + const byWorkspace = await detectWorkspaceSyncedDeliverables(pool, userId, { sinceMs }); + return mergeDeliverableSummaries(bySession, byWorkspace); +} + +export async function tryRecoverRunFromDeliverables({ + pool, + userId, + sessionId, + error = null, + requireRecoverableError = false, + prepareDeliverables = null, + runStartedAtMs = null, +} = {}) { + if (requireRecoverableError && !isRecoverableFinishError(error)) { + return null; + } + const deliverables = await prepareAndDetectSessionDeliverables({ + pool, + userId, + sessionId, + runStartedAtMs, + prepareDeliverables, + }); + if (!hasRecoverableSessionDeliverables(deliverables)) { + return null; + } return { - pageCount: pages.length, - publicationCount, - pages, + deliverables, + originalError: error + ? { + code: error?.code ?? null, + message: error instanceof Error ? error.message : String(error ?? ''), + } + : null, }; } @@ -56,19 +166,16 @@ export async function tryRecoverRunFromSessionDeliverables({ userId, sessionId, error, + prepareDeliverables = null, + runStartedAtMs = null, } = {}) { - if (!isRecoverableFinishError(error) || !sessionId) { - return null; - } - const deliverables = await detectSessionDeliverables(pool, userId, sessionId); - if (!hasRecoverableSessionDeliverables(deliverables)) { - return null; - } - return { - deliverables, - originalError: { - code: error?.code ?? null, - message: error instanceof Error ? error.message : String(error ?? ''), - }, - }; + return tryRecoverRunFromDeliverables({ + pool, + userId, + sessionId, + error, + requireRecoverableError: true, + prepareDeliverables, + runStartedAtMs, + }); } diff --git a/agent-run-deliverable-check.test.mjs b/agent-run-deliverable-check.test.mjs index ca81c5d..8d3c03d 100644 --- a/agent-run-deliverable-check.test.mjs +++ b/agent-run-deliverable-check.test.mjs @@ -2,8 +2,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { detectSessionDeliverables, + detectWorkspaceSyncedDeliverables, hasRecoverableSessionDeliverables, isRecoverableFinishError, + mergeDeliverableSummaries, + prepareAndDetectSessionDeliverables, + tryRecoverRunFromDeliverables, tryRecoverRunFromSessionDeliverables, } from './agent-run-deliverable-check.mjs'; @@ -75,3 +79,109 @@ test('hasRecoverableSessionDeliverables accepts pages even without online public assert.equal(hasRecoverableSessionDeliverables({ pageCount: 1, publicationCount: 0 }), true); assert.equal(hasRecoverableSessionDeliverables({ pageCount: 0, publicationCount: 0 }), false); }); + +test('detectWorkspaceSyncedDeliverables filters auto-synced public html pages', async () => { + const pool = { + async query(sql, params) { + assert.match(sql, /auto_synced/); + assert.deepEqual(params, ['user-1', 1000]); + return [[{ + page_id: 'page-html', + title: '攻略', + publication_id: null, + publication_status: null, + public_url: null, + }]]; + }, + }; + const summary = await detectWorkspaceSyncedDeliverables(pool, 'user-1', { sinceMs: 1000 }); + assert.equal(summary.pageCount, 1); +}); + +test('prepareAndDetectSessionDeliverables merges session and workspace pages after prepare hook', async () => { + const prepareCalls = []; + const pool = { + async query(sql, params) { + if (sql.includes('source_session_id')) { + return [[{ + page_id: 'page-session', + title: 'Session Page', + publication_id: null, + publication_status: null, + public_url: null, + }]]; + } + if (sql.includes('auto_synced')) { + return [[{ + page_id: 'page-workspace', + title: 'Workspace Page', + publication_id: 'pub-workspace', + publication_status: 'online', + public_url: '/u/john/pages/page-workspace', + }]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const summary = await prepareAndDetectSessionDeliverables({ + pool, + userId: 'user-1', + sessionId: 'session-1', + runStartedAtMs: 2000, + prepareDeliverables: async ({ userId, sessionId }) => { + prepareCalls.push({ userId, sessionId }); + }, + }); + assert.equal(prepareCalls.length, 1); + assert.equal(summary.pageCount, 2); + assert.equal(summary.publicationCount, 1); +}); + +test('tryRecoverRunFromDeliverables succeeds for stale recovery without recoverable finish error', async () => { + const pool = { + async query(sql) { + if (sql.includes('source_session_id')) return [[]]; + if (sql.includes('auto_synced')) { + return [[{ + page_id: 'page-1', + title: 'Survey', + publication_id: 'pub-1', + public_url: '/u/j/x', + }]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const recovered = await tryRecoverRunFromDeliverables({ + pool, + userId: 'user-1', + sessionId: 'session-1', + error: Object.assign(new Error('stale'), { code: 'AGENT_RUN_STALE_RECOVERY' }), + requireRecoverableError: false, + }); + assert.ok(recovered); + assert.equal(recovered.deliverables.pageCount, 1); +}); + +test('mergeDeliverableSummaries deduplicates pages by id', () => { + const merged = mergeDeliverableSummaries( + { + pageCount: 1, + publicationCount: 0, + pages: [{ pageId: 'page-1', title: 'A', publicationId: null, publicationStatus: null, publicUrl: null }], + }, + { + pageCount: 1, + publicationCount: 1, + pages: [{ + pageId: 'page-1', + title: 'A', + publicationId: 'pub-1', + publicationStatus: 'online', + publicUrl: '/u/j/x', + }], + }, + ); + assert.equal(merged.pageCount, 1); + assert.equal(merged.publicationCount, 1); +}); diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index c4e7fd3..929c724 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -11,7 +11,10 @@ import { persistSessionTranscriptMessages, } from './conversation-transcript-persist.mjs'; import { ensureGooseUserMessageMetadata } from './goose-message.mjs'; -import { tryRecoverRunFromSessionDeliverables } from './agent-run-deliverable-check.mjs'; +import { + SESSION_FINISHED_STALE_GRACE_MS, + tryRecoverRunFromDeliverables, +} from './agent-run-deliverable-check.mjs'; const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000]; const TERMINAL_STATUSES = new Set(['succeeded', 'failed']); @@ -391,12 +394,38 @@ export function createAgentRunGateway({ return projectRun(await getRunById(runId)); } - async function recoverRunFromDeliverables({ runId, userId, sessionId, err }) { - const recovered = await tryRecoverRunFromSessionDeliverables({ + async function prepareRunDeliverables({ userId, sessionId, runId, runStartedAtMs = null }) { + if (typeof syncUserPagesOnSuccess !== 'function') return; + await syncUserPagesOnSuccess({ + userId, + sessionId, + runId, + runStartedAtMs, + }).catch((err) => { + console.warn( + '[AgentRun] prepare deliverables sync failed:', + err instanceof Error ? err.message : err, + ); + }); + } + + async function recoverRunFromDeliverables({ + runId, + userId, + sessionId, + err, + runStartedAtMs = null, + requireRecoverableError = true, + }) { + const recovered = await tryRecoverRunFromDeliverables({ pool, userId, sessionId, error: err, + requireRecoverableError, + runStartedAtMs, + prepareDeliverables: ({ userId: uid, sessionId: sid }) => + prepareRunDeliverables({ userId: uid, sessionId: sid, runId, runStartedAtMs }), }); if (!recovered) return false; await appendEvent(runId, 'run_recovered_from_deliverables', { @@ -780,6 +809,7 @@ export function createAgentRunGateway({ userId: row.user_id, sessionId: recoverySessionId, err, + runStartedAtMs: latest?.started_at ?? row.started_at ?? null, })) { await finalizeSuccessfulRun(runId, row, recoverySessionId); return; @@ -913,18 +943,27 @@ export function createAgentRunGateway({ limit = maxConcurrentRuns, dryRun = true, reason = 'stale_running_timeout', + sessionFinishedGraceMs = SESSION_FINISHED_STALE_GRACE_MS, } = {}) { const normalizedStaleMs = positiveInteger(staleMs, runTimeoutMs); const normalizedLimit = positiveInteger(limit, maxConcurrentRuns); - const cutoff = nowMs() - normalizedStaleMs; + const normalizedSessionFinishedGraceMs = positiveInteger( + sessionFinishedGraceMs, + SESSION_FINISHED_STALE_GRACE_MS, + ); + const startedCutoff = nowMs() - normalizedStaleMs; + const sessionFinishedCutoff = nowMs() - normalizedSessionFinishedGraceMs; const [rows] = await pool.query( `SELECT r.id, + r.user_id, + r.agent_session_id, r.request_id, r.started_at, r.updated_at, r.attempts, - h.latest_heartbeat_at + h.latest_heartbeat_at, + sf.session_finished_at FROM h5_agent_runs r LEFT JOIN ( SELECT run_id, MAX(created_at) AS latest_heartbeat_at @@ -932,18 +971,31 @@ export function createAgentRunGateway({ WHERE event_type = 'worker_heartbeat' GROUP BY run_id ) h ON h.run_id = r.id + LEFT JOIN ( + SELECT run_id, MAX(created_at) AS session_finished_at + FROM h5_agent_run_events + WHERE event_type = 'session_finished' + GROUP BY run_id + ) sf ON sf.run_id = r.id WHERE r.status = 'running' AND r.started_at IS NOT NULL - AND COALESCE(h.latest_heartbeat_at, r.started_at) <= ? - ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC + AND ( + r.started_at <= ? + OR ( + sf.session_finished_at IS NOT NULL + AND sf.session_finished_at <= ? + ) + ) + ORDER BY COALESCE(sf.session_finished_at, r.started_at) ASC LIMIT ?`, - [cutoff, normalizedLimit], + [startedCutoff, sessionFinishedCutoff, normalizedLimit], ); const recovered = []; for (const row of rows) { const ageMs = Math.max(0, nowMs() - Number(row.started_at ?? 0)); const heartbeatAt = row.latest_heartbeat_at == null ? null : Number(row.latest_heartbeat_at); const heartbeatAgeMs = Math.max(0, nowMs() - Number(row.latest_heartbeat_at ?? row.started_at ?? 0)); + const sessionFinishedAt = row.session_finished_at == null ? null : Number(row.session_finished_at); const item = { id: row.id, requestId: row.request_id, @@ -952,13 +1004,40 @@ export function createAgentRunGateway({ attempts: Number(row.attempts ?? 0), heartbeatAt, heartbeatAgeMs, + sessionFinishedAt, ageMs, reason, }; if (!dryRun) { - const message = heartbeatAt == null - ? `agent run recovered from stale running state after ${ageMs}ms without heartbeat` - : `agent run recovered from stale running state after heartbeat was stale for ${heartbeatAgeMs}ms`; + const staleError = Object.assign( + new Error(`agent run stale after ${ageMs}ms`), + { code: 'AGENT_RUN_STALE_RECOVERY' }, + ); + const deliverableRecovered = await recoverRunFromDeliverables({ + runId: row.id, + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + err: staleError, + runStartedAtMs: row.started_at ?? null, + requireRecoverableError: false, + }); + if (deliverableRecovered) { + await markRun(row.id, 'succeeded', { + agent_session_id: row.agent_session_id ?? null, + completed_at: nowMs(), + error_message: null, + }); + item.status = 'succeeded'; + item.recoveredAs = 'deliverables'; + recovered.push(item); + continue; + } + + const message = sessionFinishedAt != null + ? `agent run recovered from stale running state after session finished ${Math.max(0, nowMs() - sessionFinishedAt)}ms ago` + : heartbeatAt == null + ? `agent run recovered from stale running state after ${ageMs}ms without heartbeat` + : `agent run recovered from stale running state after running for ${ageMs}ms`; const completedAt = nowMs(); const [update] = await pool.query( `UPDATE h5_agent_runs @@ -966,33 +1045,41 @@ export function createAgentRunGateway({ WHERE id = ? AND status = 'running' AND started_at IS NOT NULL - AND COALESCE( - (SELECT MAX(created_at) - FROM h5_agent_run_events - WHERE run_id = h5_agent_runs.id AND event_type = 'worker_heartbeat'), - started_at - ) <= ?`, - [message, completedAt, completedAt, row.id, cutoff], + AND ( + started_at <= ? + OR EXISTS ( + SELECT 1 + FROM h5_agent_run_events sf + WHERE sf.run_id = h5_agent_runs.id + AND sf.event_type = 'session_finished' + AND sf.created_at <= ? + ) + )`, + [message, completedAt, completedAt, row.id, startedCutoff, sessionFinishedCutoff], ); if (Number(update?.affectedRows ?? 0) === 0) continue; await appendEvent(row.id, 'stale_recovered', { reason, staleMs: normalizedStaleMs, + sessionFinishedGraceMs: normalizedSessionFinishedGraceMs, ageMs, heartbeatAt, heartbeatAgeMs, + sessionFinishedAt, status: 'failed', error: message, }); + item.status = 'failed'; } recovered.push(item); } return { dryRun, staleMs: normalizedStaleMs, - cutoff, + startedCutoff, + sessionFinishedCutoff, considered: rows.length, - recovered: dryRun ? 0 : recovered.length, + recovered: dryRun ? 0 : recovered.filter((item) => item.status).length, runs: recovered, }; } diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 2137544..dc4872b 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -6,10 +6,26 @@ import path from 'node:path'; import test from 'node:test'; import { createAgentRunGateway } from './agent-run-gateway.mjs'; -function createFakePool({ sessionDeliverables = {} } = {}) { +function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } = {}) { const runs = new Map(); const events = []; + const sessionFinishedAt = (runId) => { + const timestamps = events + .filter((event) => event.runId === runId && event.eventType === 'session_finished') + .map((event) => Number(event.createdAt ?? 0)); + return timestamps.length ? Math.max(...timestamps) : null; + }; + + const isStaleRunningRow = (row, startedCutoff, sessionFinishedCutoff) => { + if (row.status !== 'running' || row.started_at == null) return false; + const finishedAt = sessionFinishedAt(row.id); + return ( + Number(row.started_at) <= Number(startedCutoff) + || (finishedAt != null && Number(finishedAt) <= Number(sessionFinishedCutoff)) + ); + }; + const latestHeartbeatAt = (runId) => { const timestamps = events .filter((event) => event.runId === runId && event.eventType === 'worker_heartbeat') @@ -69,6 +85,28 @@ function createFakePool({ sessionDeliverables = {} } = {}) { .length, }]]; } + if (sql.includes('SELECT') && sql.includes('session_finished_at') && sql.includes('r.started_at <= ?')) { + const [startedCutoff, sessionFinishedCutoff, limit = 1] = params; + return [[...runs.values()] + .filter((row) => isStaleRunningRow(row, startedCutoff, sessionFinishedCutoff)) + .sort((a, b) => { + const aKey = Number(sessionFinishedAt(a.id) ?? a.started_at ?? 0); + const bKey = Number(sessionFinishedAt(b.id) ?? b.started_at ?? 0); + return aKey - bKey; + }) + .slice(0, Number(limit)) + .map((row) => ({ + id: row.id, + user_id: row.user_id, + agent_session_id: row.agent_session_id, + request_id: row.request_id, + started_at: row.started_at, + updated_at: row.updated_at, + attempts: row.attempts, + latest_heartbeat_at: latestHeartbeatAt(row.id), + session_finished_at: sessionFinishedAt(row.id), + }))]; + } if (sql.includes('SELECT') && sql.includes('latest_heartbeat_at') && sql.includes('COALESCE(h.latest_heartbeat_at, r.started_at) <= ?')) { const [cutoff, limit = 1] = params; return [[...runs.values()] @@ -172,6 +210,20 @@ function createFakePool({ sessionDeliverables = {} } = {}) { }); return [{ affectedRows: 1 }]; } + if (sql.includes("WHERE id = ?") && sql.includes("status = 'running'") && sql.includes('session_finished')) { + const [errorMessage, updatedAt, completedAt, id, startedCutoff, sessionFinishedCutoff] = params; + const row = runs.get(id); + if (!row || !isStaleRunningRow(row, startedCutoff, sessionFinishedCutoff)) { + return [{ affectedRows: 0 }]; + } + Object.assign(row, { + status: 'failed', + error_message: errorMessage, + updated_at: updatedAt, + completed_at: completedAt, + }); + return [{ affectedRows: 1 }]; + } if (sql.includes("WHERE id = ?") && sql.includes("status = 'running'") && sql.includes('worker_heartbeat')) { const [errorMessage, updatedAt, completedAt, id, cutoff] = params; const row = runs.get(id); @@ -195,6 +247,15 @@ function createFakePool({ sessionDeliverables = {} } = {}) { const [userId, sessionId] = params; return [sessionDeliverables[`${userId}:${sessionId}`] ?? []]; } + if (sql.includes('FROM h5_page_records p') && sql.includes('auto_synced')) { + const userId = params[0]; + const sinceMs = params.length > 1 ? Number(params[1]) : null; + let rows = workspaceDeliverables[userId] ?? []; + if (sinceMs != null) { + rows = rows.filter((row) => Number(row.updated_at ?? 0) >= sinceMs); + } + return [rows]; + } if (sql.includes('UPDATE h5_agent_runs SET')) { const id = params.at(-1); const row = runs.get(id); @@ -1420,7 +1481,7 @@ test('stale running recovery marks old running rows failed with an event', async ); }); -test('stale running recovery ignores old running rows with fresh heartbeat', async () => { +test('stale running recovery still considers old runs even with fresh heartbeat', async () => { const pool = createFakePool(); const gateway = createAgentRunGateway({ pool, @@ -1450,9 +1511,60 @@ test('stale running recovery ignores old running rows with fresh heartbeat', asy const result = await gateway.recoverStaleRunningRuns({ staleMs: 1000, dryRun: false }); - assert.equal(result.considered, 0); - assert.equal(result.recovered, 0); - assert.equal(pool.runs.get(run.id).status, 'running'); + assert.equal(result.considered, 1); + assert.equal(result.recovered, 1); + assert.equal(pool.runs.get(run.id).status, 'failed'); +}); + +test('stale running recovery succeeds when workspace pages exist after sync', async () => { + const startedAt = Date.now() - 5000; + const pool = createFakePool({ + workspaceDeliverables: { + 'user-1': [{ + page_id: 'page-synced', + title: '苏州攻略', + publication_id: 'pub-synced', + publication_status: 'online', + public_url: 'http://127.0.0.1:5173/u/john/pages/page-synced', + updated_at: startedAt + 1000, + }], + }, + }); + const syncCalls = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: {}, + autoDispatch: false, + runTimeoutMs: 1000, + syncUserPagesOnSuccess: async ({ userId, sessionId, runId }) => { + syncCalls.push({ userId, sessionId, runId }); + }, + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-stale-deliverable', + sessionId: 'session-stale-deliverable', + userMessage: { role: 'user', content: [] }, + }); + Object.assign(pool.runs.get(run.id), { + status: 'running', + attempts: 1, + agent_session_id: 'session-stale-deliverable', + started_at: startedAt, + updated_at: startedAt, + }); + + const result = await gateway.recoverStaleRunningRuns({ staleMs: 1000, dryRun: false }); + + assert.equal(result.considered, 1); + assert.equal(result.recovered, 1); + assert.equal(pool.runs.get(run.id).status, 'succeeded'); + assert.equal(syncCalls.length, 1); + assert.equal( + pool.events.some((event) => event.runId === run.id && event.eventType === 'run_recovered_from_deliverables'), + true, + ); }); test('queue status reports running heartbeat age and missing heartbeat count', async () => { diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index fadb4a1..9941342 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -55,6 +55,7 @@ export function createPostAgentRunsHandler({ userAuth, sessionAccess = null, agentRunGateway, + mindSpaceAssetAgent = null, codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), codeRunUserIds = parseUserIdSet(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), codeRunTaskTypes = parseTaskTypeSet(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES), @@ -117,6 +118,36 @@ export function createPostAgentRunsHandler({ return; } } + const selectedAssetIds = [ + ...new Set( + (Array.isArray(request.body?.selected_asset_ids) + ? request.body.selected_asset_ids + : Array.isArray(request.body?.selectedAssetIds) + ? request.body.selectedAssetIds + : [] + ) + .map((item) => String(item ?? '').trim()) + .filter(Boolean), + ), + ]; + if ( + selectedAssetIds.length > 0 && + mindSpaceAssetAgent?.materializeAssetsForSession + ) { + try { + await mindSpaceAssetAgent.materializeAssetsForSession({ + sessionId, + userId: request.currentUser.id, + assetIds: selectedAssetIds, + }); + } catch (err) { + response.status(400).json({ + message: err instanceof Error ? err.message : '勾选资料落盘失败', + code: err?.code ?? 'asset_materialize_failed', + }); + return; + } + } const run = await agentRunGateway.createRun(request.currentUser.id, { sessionId, requestId, diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index 7368e52..4affb9c 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -746,3 +746,46 @@ test('GET /agent/runs/:runId/events republishes changed run state before termina assert.match(output, /"status":"succeeded"/); assert.deepEqual(dispatched, ['run-2']); }); + +test('POST /agent/runs materializes selected assets before creating run', async () => { + const materialized = []; + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + return true; + }, + }, + mindSpaceAssetAgent: { + async materializeAssetsForSession(payload) { + materialized.push(payload); + return { materialized: [{ relativePath: 'oa/sales.xlsx' }] }; + }, + }, + agentRunGateway: { + async createRun(userId, payload) { + return { id: 'run-1', status: 'queued', userId, payload }; + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + session_id: 'session-1', + request_id: 'req-1', + user_message: { role: 'user', content: [{ type: 'text', text: 'analyze' }] }, + selected_asset_ids: ['asset-9'], + }, + }, + res, + ); + + assert.equal(res.statusCode, 202); + assert.deepEqual(materialized, [{ + sessionId: 'session-1', + userId: 'user-1', + assetIds: ['asset-9'], + }]); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index 73a159e..edae315 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -174,8 +174,9 @@ export function buildChatSkillPrompt(promptKey, skillName) { return '请深入分析以下内容,给出结构化解读:背景、关键发现、风险或机会、以及建议下一步:'; case 'generate-page': return ( - `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。` + - '默认只生成 HTML:必须先 load_skill,再用 write_file/edit_file 写入 public/页面.html;完成后直接返回 Markdown 可点击公网链接 `[页面标题](URL)`。' + + `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成 HTML 页面,并给出可公网访问的完整链接。` + + '默认只生成 HTML:必须先 load_skill,再用 write_file/edit_file 写入 public/页面.html;允许图表、动画等动态效果,但脚本必须适合沙箱发布,优先使用平台已支持的本地/可信库,禁止 javascript:、onxxx 内联事件、未知第三方脚本和跨用户私有资源。' + + '完成前必须验证文件已写入、页面安全检查/发布状态可用、链接返回 200 且正文包含关键数据;验证失败时不要宣称“已完成”,应修复后再回复 Markdown 可点击公网链接 `[页面标题](URL)`。' + '禁止只给本地路径(如 hello/index.html),禁止询问是否还要发布,除非写文件工具实际失败。' + '只有用户明确要求 Word / docx 下载时,才先 `load_skill` → `docx-generate`,生成 `public/*.docx` 并确认文件已存在,再写 HTML 并用同目录相对路径链接该文档。' + '只有用户明确要求长图下载时,才先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成 `public/*.long.png` 并确认文件已存在,再给长图预览链接和 `?download=long-image` 下载链接。不要在没有明确需求时强制生成伴生文件。' diff --git a/mindspace-asset-agent.mjs b/mindspace-asset-agent.mjs index 3599b89..35b362c 100644 --- a/mindspace-asset-agent.mjs +++ b/mindspace-asset-agent.mjs @@ -1,3 +1,74 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-path.mjs'; +import { ensureUserZoneDirs } from './user-space.mjs'; + +function sanitizeBasename(value) { + const normalized = String(value ?? '').replace(/\\/g, '/').trim(); + const basename = normalized.split('/').pop() ?? ''; + return basename.replace(/[^\w.\-()\u4e00-\u9fff]+/g, '_') || 'file'; +} + +async function materializeAssetToWorkspace({ + assetService, + resolveWorkspaceRoot, + userId, + assetId, +}) { + const { asset, path: sourcePath } = await assetService.readAsset(userId, assetId); + const workspaceRoot = await resolveWorkspaceRoot(userId); + ensureUserZoneDirs(workspaceRoot); + const filename = sanitizeBasename(asset.filename ?? asset.displayName ?? asset.id); + const relativePath = + resolveAssetWorkspaceRelativePath({ + categoryCode: asset.categoryCode ?? 'oa', + originalFilename: filename, + }) ?? `oa/${filename}`; + const destPath = path.join(workspaceRoot, ...relativePath.split('/')); + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(sourcePath, destPath); + const stat = await fs.stat(destPath); + return { + assetId, + relativePath, + sizeBytes: stat.size, + displayName: asset.displayName ?? filename, + mimeType: asset.mimeType ?? null, + }; +} + +export function buildSelectedAssetReadInstructions(context) { + const selected = context.selectedAssets ?? []; + if (selected.length !== 1) return []; + + const asset = selected[0]; + const h5ApiBase = String(context.h5ApiBase ?? '').replace(/\/$/, ''); + const sessionId = String(context.agentSessionId ?? '').trim(); + if (!h5ApiBase) return []; + + const sessionToken = sessionId || ''; + const sessionHint = sessionId + ? '' + : '- session_id 未写入上下文时,使用当前 Agent 会话 ID 替换 ``。\n'; + + return [ + '【勾选资料读取(硬性)】', + `- 用户已勾选「${asset.displayName}」(id: ${asset.id})`, + '- **禁止** curl / fetch 浏览器登录接口 `GET /api/mindspace/v1/assets/:id/download`(goosed 无 cookie,会 401)', + '- **优先**用 `list_dir oa` / `read_file` / `cat oa/文件名` 读取工作区已落盘副本', + sessionHint, + '- 若工作区尚无该文件,**必须**用 shell 调用 Agent 代理下载并落盘到 `oa/`:', + '```bash', + `curl -sS -X POST '${h5ApiBase}/api/agent/mindspace_asset_download' \\`, + " -H 'Content-Type: application/json' \\", + ` -d '{"session_id":"${sessionToken}","asset_id":"${asset.id}"}'`, + '```', + '- 成功后响应 JSON 含 `relativePath`(如 `oa/report.xlsx`),后续分析/引用只用该相对路径', + '- **禁止**访问其它用户目录、MindSpace 根目录或 `./MindSpace//` 等猜测路径', + '', + ].filter(Boolean); +} + export function buildSelectedAssetDeleteInstructions(context) { const selected = context.selectedAssets ?? []; if (selected.length !== 1) return []; @@ -29,7 +100,24 @@ export function buildSelectedAssetDeleteInstructions(context) { ].filter(Boolean); } -export function createAssetAgentService({ assetService, resolveUserIdForAgentSession }) { +export function createAssetAgentService({ + assetService, + resolveUserIdForAgentSession, + resolveWorkspaceRoot, +}) { + if (typeof resolveWorkspaceRoot !== 'function') { + throw new Error('resolveWorkspaceRoot is required for asset agent service'); + } + + async function resolveOwnedUserId(sessionId, explicitUserId = null) { + if (explicitUserId) return explicitUserId; + const userId = await resolveUserIdForAgentSession(sessionId); + if (!userId) { + throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' }); + } + return userId; + } + return { async applyAgentDelete(input = {}) { const sessionId = String(input?.sessionId ?? input?.session_id ?? '').trim(); @@ -42,11 +130,7 @@ export function createAssetAgentService({ assetService, resolveUserIdForAgentSes throw Object.assign(new Error('删除前须已在对话中获得用户确认'), { code: 'confirmation_required' }); } - const userId = await resolveUserIdForAgentSession(sessionId); - if (!userId) { - throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' }); - } - + const userId = await resolveOwnedUserId(sessionId); const result = await assetService.deleteAsset(userId, assetId); return { assetId, @@ -54,5 +138,53 @@ export function createAssetAgentService({ assetService, resolveUserIdForAgentSes ...result, }; }, + + async applyAgentDownload(input = {}) { + const sessionId = String(input?.sessionId ?? input?.session_id ?? '').trim(); + const assetId = String(input?.assetId ?? input?.asset_id ?? '').trim(); + if (!sessionId || !assetId) { + throw Object.assign(new Error('缺少 session_id 或 asset_id'), { code: 'invalid_request' }); + } + + const userId = await resolveOwnedUserId(sessionId); + const materialized = await materializeAssetToWorkspace({ + assetService, + resolveWorkspaceRoot, + userId, + assetId, + }); + return { + userId, + ...materialized, + }; + }, + + async materializeAssetsForSession({ + sessionId = null, + userId = null, + assetIds = [], + } = {}) { + const normalizedIds = [...new Set( + (Array.isArray(assetIds) ? assetIds : []) + .map((item) => String(item ?? '').trim()) + .filter(Boolean), + )]; + if (normalizedIds.length === 0) { + return { materialized: [] }; + } + const resolvedUserId = await resolveOwnedUserId(sessionId, userId); + const materialized = []; + for (const assetId of normalizedIds) { + materialized.push( + await materializeAssetToWorkspace({ + assetService, + resolveWorkspaceRoot, + userId: resolvedUserId, + assetId, + }), + ); + } + return { materialized }; + }, }; } diff --git a/mindspace-asset-agent.test.mjs b/mindspace-asset-agent.test.mjs index b90d554..2fadf54 100644 --- a/mindspace-asset-agent.test.mjs +++ b/mindspace-asset-agent.test.mjs @@ -1,10 +1,33 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { buildSelectedAssetDeleteInstructions, + buildSelectedAssetReadInstructions, createAssetAgentService, } from './mindspace-asset-agent.mjs'; +test('buildSelectedAssetReadInstructions uses agent download proxy', () => { + const lines = buildSelectedAssetReadInstructions({ + selectedAssets: [ + { + id: 'asset-9', + displayName: 'sales.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + ], + h5ApiBase: 'http://127.0.0.1:8081', + agentSessionId: 'session-1', + }); + const text = lines.join('\n'); + assert.match(text, /mindspace_asset_download/); + assert.match(text, /禁止.*assets\/:id\/download/); + assert.match(text, /asset-9/); + assert.match(text, /session-1/); +}); + test('buildSelectedAssetDeleteInstructions includes agent delete curl', () => { const lines = buildSelectedAssetDeleteInstructions({ selectedAssets: [ @@ -32,6 +55,7 @@ test('applyAgentDelete requires confirmation and valid session', async () => { }, resolveUserIdForAgentSession: async (sessionId) => sessionId === 'session-1' ? 'user-1' : null, + resolveWorkspaceRoot: async () => '/tmp/workspace', }); await assert.rejects( @@ -47,3 +71,64 @@ test('applyAgentDelete requires confirmation and valid session', async () => { assert.equal(result.deleted, true); assert.equal(result.userId, 'user-1'); }); + +test('applyAgentDownload materializes asset into workspace oa/', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'asset-agent-')); + const sourcePath = path.join(workspaceRoot, 'source.xlsx'); + await fs.writeFile(sourcePath, 'excel-bytes'); + + const service = createAssetAgentService({ + assetService: { + readAsset: async () => ({ + asset: { + id: 'asset-9', + categoryCode: 'oa', + filename: 'sales.xlsx', + displayName: 'sales.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + path: sourcePath, + }), + }, + resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => workspaceRoot, + }); + + const result = await service.applyAgentDownload({ + session_id: 'session-1', + asset_id: 'asset-9', + }); + assert.equal(result.relativePath, 'oa/sales.xlsx'); + const copied = await fs.readFile(path.join(workspaceRoot, 'oa/sales.xlsx'), 'utf8'); + assert.equal(copied, 'excel-bytes'); +}); + +test('materializeAssetsForSession accepts explicit userId', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'asset-agent-')); + const sourcePath = path.join(workspaceRoot, 'source.csv'); + await fs.writeFile(sourcePath, 'a,b\n1,2'); + + const service = createAssetAgentService({ + assetService: { + readAsset: async (_userId, assetId) => ({ + asset: { + id: assetId, + categoryCode: 'oa', + filename: 'data.csv', + displayName: 'data.csv', + mimeType: 'text/csv', + }, + path: sourcePath, + }), + }, + resolveUserIdForAgentSession: async () => null, + resolveWorkspaceRoot: async () => workspaceRoot, + }); + + const result = await service.materializeAssetsForSession({ + userId: 'user-1', + assetIds: ['asset-a'], + }); + assert.equal(result.materialized.length, 1); + assert.equal(result.materialized[0].relativePath, 'oa/data.csv'); +}); diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index e7635e4..68e3c68 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -61,6 +61,26 @@ function asNumber(value) { return Number(value ?? 0); } +function scanOptionsForCategoryFile(categoryCode, filename, mimeType) { + const normalizedName = String(filename ?? '').toLowerCase(); + if ( + categoryCode === 'public' && + mimeType === 'text/html' && + normalizedName.endsWith('.html') + ) { + return { htmlActiveContentPolicy: 'sandbox_warn' }; + } + return {}; +} + +function assetStatusForScan(scan) { + return scan.scanStatus === 'blocked' ? 'quarantined' : 'ready'; +} + +function versionStatusForScan(scan) { + return scan.scanStatus === 'blocked' ? 'blocked' : scan.scanStatus; +} + function normalizeFilename(filename) { const normalized = String(filename ?? '').normalize('NFKC').trim(); if ( @@ -636,9 +656,10 @@ export function createAssetService(pool, options = {}) { const scan = runBasicFileScan(storedBuffer, { filename: storedUploadFilename, mimeType: storedMimeType, + ...scanOptionsForCategoryFile(upload.category_code, storedUploadFilename, storedMimeType), }); - const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; - const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const assetStatus = assetStatusForScan(scan); + const versionScanStatus = versionStatusForScan(scan); const isImage = storedMimeType.startsWith('image/'); const shouldPublishImage = isImage && scan.scanStatus === 'passed'; let finalStorageKey = upload.temporary_storage_key; @@ -1188,9 +1209,10 @@ export function createAssetService(pool, options = {}) { const scan = runBasicFileScan(storedBuffer, { filename: storedFilenameInput, mimeType: storedMimeType, + ...scanOptionsForCategoryFile(category.category_code, storedFilenameInput, storedMimeType), }); - const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; - const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const assetStatus = assetStatusForScan(scan); + const versionScanStatus = versionStatusForScan(scan); const shouldPublishImage = isImage && scan.scanStatus === 'passed'; let finalStorageKey = path.posix.join('users', userId, 'assets', assetId, 'versions', versionId); let workspaceRelativePath = null; diff --git a/mindspace-chat-context.mjs b/mindspace-chat-context.mjs index 54a3819..4d6aa69 100644 --- a/mindspace-chat-context.mjs +++ b/mindspace-chat-context.mjs @@ -1,5 +1,8 @@ import { buildMindSpacePageSaveInstructions } from './mindspace-page-patch.mjs'; -import { buildSelectedAssetDeleteInstructions } from './mindspace-asset-agent.mjs'; +import { + buildSelectedAssetDeleteInstructions, + buildSelectedAssetReadInstructions, +} from './mindspace-asset-agent.mjs'; const VIEW_LABELS = { home: '空间首页', @@ -107,7 +110,7 @@ function buildSelectedAssetInstructions(context) { lines.push( `- 用户在界面勾选了 1 份${kind}:「${asset.displayName}」(id: ${asset.id},${asset.mimeType})`, '- 用户说「这个 / 这份 / 这篇 / 这份报告 / 这个数据 / 帮我分析 / 帮我删除」等指代词时,**必须**指这份勾选资料,不得猜测其它文件。', - `- 读取/下载:GET /api/mindspace/v1/assets/${asset.id}/download`, + '- 读取/分析:优先 `list_dir oa` / `read_file oa/文件名`;若工作区尚无副本,见下方「勾选资料读取」用 Agent 代理下载落盘。', '- 删除:须先与用户确认,确认后由你直接执行删除(见下方「勾选资料删除」);你对该用户空间有完整读写删改权限,**禁止**推给用户自行去界面删除。', ); if (context.h5ApiBase) { @@ -363,6 +366,7 @@ export function buildContextPrefix(context) { } lines.push(...buildSelectedAssetInstructions(context)); + lines.push(...buildSelectedAssetReadInstructions(context)); lines.push(...buildSelectedAssetDeleteInstructions(context)); if (context.homeCategories?.length) { diff --git a/mindspace-chat-context.test.mjs b/mindspace-chat-context.test.mjs index ff4c9d3..23c8155 100644 --- a/mindspace-chat-context.test.mjs +++ b/mindspace-chat-context.test.mjs @@ -220,6 +220,8 @@ test('buildContextPrefix binds deictic references to a single checked asset', () assert.match(prefix, /【界面勾选资料(硬性)】/); assert.match(prefix, /sales\.xlsx(id: asset-9/); + assert.match(prefix, /【勾选资料读取(硬性)】/); + assert.match(prefix, /mindspace_asset_download/); assert.match(prefix, /【勾选资料删除(硬性)】/); assert.match(prefix, /mindspace_asset_delete/); assert.match(prefix, /完整读写删改权限/); diff --git a/mindspace-local-runtime-services.mjs b/mindspace-local-runtime-services.mjs index 6e97efc..5af8f24 100644 --- a/mindspace-local-runtime-services.mjs +++ b/mindspace-local-runtime-services.mjs @@ -17,6 +17,7 @@ export function createMindSpaceLocalRuntimeServices({ maxFileBytes, publicPageLimit, resolveUserIdForAgentSession, + resolveWorkspaceRoot, conversationPackageBackfill, conversationPackagePublicHtmlHydrator, logger = console, @@ -83,6 +84,7 @@ export function createMindSpaceLocalRuntimeServices({ const assetAgentService = createAssetAgentService({ assetService, resolveUserIdForAgentSession, + resolveWorkspaceRoot, }); return { diff --git a/mindspace-local-runtime-services.test.mjs b/mindspace-local-runtime-services.test.mjs index b807dfb..19b09a0 100644 --- a/mindspace-local-runtime-services.test.mjs +++ b/mindspace-local-runtime-services.test.mjs @@ -18,6 +18,7 @@ test('createMindSpaceLocalRuntimeServices assembles local runtime-backed service maxFileBytes: 8192, publicPageLimit: 8, resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => '/tmp/workspace', conversationPackageBackfill: () => [], conversationPackagePublicHtmlHydrator: () => [], logger: { info() {}, warn() {}, error() {} }, diff --git a/mindspace-local-server-adapter.mjs b/mindspace-local-server-adapter.mjs index 34cd2e8..2d2b96b 100644 --- a/mindspace-local-server-adapter.mjs +++ b/mindspace-local-server-adapter.mjs @@ -9,6 +9,7 @@ export function createMindSpaceLocalServerAdapter({ maxFileBytes, publicPageLimit, resolveUserIdForAgentSession, + resolveWorkspaceRoot = null, resolveSessionSnapshot, registerPublicHtmlArtifactsForConversation, logger = console, @@ -23,6 +24,7 @@ export function createMindSpaceLocalServerAdapter({ maxFileBytes, publicPageLimit, resolveUserIdForAgentSession, + resolveWorkspaceRoot, conversationPackageBackfill: ({ user, sessionId, storageRoot, registry }) => backfillConversationPackageArtifacts({ pool, diff --git a/mindspace-local-server-adapter.test.mjs b/mindspace-local-server-adapter.test.mjs index 1be1ed7..4598ca6 100644 --- a/mindspace-local-server-adapter.test.mjs +++ b/mindspace-local-server-adapter.test.mjs @@ -48,6 +48,7 @@ test('createMindSpaceLocalServerAdapter wires backfill and public html hydrator maxFileBytes: 8192, publicPageLimit: 8, resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => path.join(h5Root, 'MindSpace', 'user-1'), resolveSessionSnapshot: async (sessionId) => snapshots.get(sessionId) ?? null, registerPublicHtmlArtifactsForConversation: async (payload) => { registered.push(payload); @@ -88,6 +89,7 @@ test('createMindSpaceLocalServerAdapter starts publication cleanup and agent wor maxFileBytes: 8192, publicPageLimit: 8, resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => path.join(h5Root, 'MindSpace', 'user-1'), logger: { log: (...args) => loggerCalls.push(['log', ...args]), warn: (...args) => loggerCalls.push(['warn', ...args]), @@ -161,6 +163,7 @@ test('createMindSpaceLocalServerAdapter startBackgroundJobs aggregates startup s maxFileBytes: 8192, publicPageLimit: 8, resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => path.join(h5Root, 'MindSpace', 'user-1'), logger: { log() {}, warn() {}, error() {} }, setIntervalFn: (fn, ms) => { const timer = { fn, ms, unref() {} }; diff --git a/mindspace-scan.mjs b/mindspace-scan.mjs index cda6d7f..ec069c1 100644 --- a/mindspace-scan.mjs +++ b/mindspace-scan.mjs @@ -1,10 +1,16 @@ -const SCRIPT_PATTERNS = [ - /]*)>([\s\S]*?)<\/script>/gi; +const SCRIPT_SRC_PATTERN = /\bsrc\s*=\s*(['"])([^'"]+)\1/i; +const TRUSTED_SCRIPT_SRC_PATTERNS = [ + /^\/assets\/chart\.umd\.min\.js(?:[?#].*)?$/i, + /^https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js(?:@[^/]+)?\/dist\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i, + /^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/Chart\.js\/[^/]+\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i, + /^https:\/\/unpkg\.com\/chart\.js(?:@[^/]+)?\/dist\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i, ]; const MAX_ZIP_COMPRESSION_RATIO = 100; @@ -19,17 +25,53 @@ function isZipContainer(mimeType) { ); } -function scanTextContent(buffer) { +function isTrustedScriptSrc(src) { + const normalized = String(src ?? '').trim().replace(/^\/\//, 'https://'); + return TRUSTED_SCRIPT_SRC_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +function scanScriptTags(sample, { htmlActiveContentPolicy }) { + const scripts = [...sample.matchAll(SCRIPT_TAG_PATTERN)]; + if (!scripts.length) return null; + if (htmlActiveContentPolicy !== 'sandbox_warn') { + return { + scanStatus: 'blocked', + riskLevel: 'high', + findings: ['text_active_content'], + }; + } + const untrusted = scripts.some((match) => { + const attrs = match[1] ?? ''; + const src = attrs.match(SCRIPT_SRC_PATTERN)?.[2] ?? null; + return src ? !isTrustedScriptSrc(src) : false; + }); + if (untrusted) { + return { + scanStatus: 'blocked', + riskLevel: 'high', + findings: ['text_active_content'], + }; + } + return { + scanStatus: 'warned', + riskLevel: 'medium', + findings: ['trusted_html_active_content'], + }; +} + +function scanTextContent(buffer, options = {}) { const sample = buffer.subarray(0, Math.min(buffer.length, 256 * 1024)).toString('utf8'); - for (const pattern of SCRIPT_PATTERNS) { + for (const { type, pattern } of BLOCKED_ACTIVE_PATTERNS) { if (pattern.test(sample)) { return { scanStatus: 'blocked', riskLevel: 'high', - findings: ['text_active_content'], + findings: [type], }; } } + const scriptFinding = scanScriptTags(sample, options); + if (scriptFinding) return scriptFinding; return null; } @@ -72,7 +114,7 @@ function scanZipStructure(buffer) { return null; } -export function runBasicFileScan(buffer, { filename, mimeType }) { +export function runBasicFileScan(buffer, { filename, mimeType, htmlActiveContentPolicy = 'block' }) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) { return { scanStatus: 'blocked', @@ -82,7 +124,7 @@ export function runBasicFileScan(buffer, { filename, mimeType }) { } if (mimeType.startsWith('text/') || mimeType === 'application/pdf') { - const textFinding = scanTextContent(buffer); + const textFinding = scanTextContent(buffer, { filename, mimeType, htmlActiveContentPolicy }); if (textFinding) return textFinding; } diff --git a/mindspace-scan.test.mjs b/mindspace-scan.test.mjs index c8f7d6d..a7f4d96 100644 --- a/mindspace-scan.test.mjs +++ b/mindspace-scan.test.mjs @@ -19,6 +19,43 @@ test('runBasicFileScan blocks inline script payloads', () => { assert.equal(result.riskLevel, 'high'); }); +test('runBasicFileScan warns for sandboxed html with inline script and trusted Chart.js', () => { + const result = runBasicFileScan( + Buffer.from(` + + `), + { + filename: 'dashboard.html', + mimeType: 'text/html', + htmlActiveContentPolicy: 'sandbox_warn', + }, + ); + assert.equal(result.scanStatus, 'warned'); + assert.equal(result.riskLevel, 'medium'); + assert.deepEqual(result.findings, ['trusted_html_active_content']); +}); + +test('runBasicFileScan still blocks unsafe html active content in sandbox mode', () => { + const javascriptUrl = runBasicFileScan(Buffer.from('go'), { + filename: 'dashboard.html', + mimeType: 'text/html', + htmlActiveContentPolicy: 'sandbox_warn', + }); + assert.equal(javascriptUrl.scanStatus, 'blocked'); + assert.deepEqual(javascriptUrl.findings, ['javascript_url']); + + const unknownScript = runBasicFileScan( + Buffer.from(''), + { + filename: 'dashboard.html', + mimeType: 'text/html', + htmlActiveContentPolicy: 'sandbox_warn', + }, + ); + assert.equal(unknownScript.scanStatus, 'blocked'); + assert.deepEqual(unknownScript.findings, ['text_active_content']); +}); + test('runBasicFileScan blocks suspicious zip compression ratio', () => { const buffer = Buffer.alloc(64); buffer.write('PK', 0); diff --git a/mindspace-service/mindspace-service-bootstrap.mjs b/mindspace-service/mindspace-service-bootstrap.mjs index 8f946e8..a481812 100644 --- a/mindspace-service/mindspace-service-bootstrap.mjs +++ b/mindspace-service/mindspace-service-bootstrap.mjs @@ -170,6 +170,7 @@ export async function bootstrapMindSpaceService({ maxFileBytes: runtime.maxFileBytes, publicPageLimit: runtime.publicPageLimit, resolveUserIdForAgentSession: (sessionId) => resolveUserIdForAgentSession(pool, sessionId), + resolveWorkspaceRoot: (userId) => userAuth.resolveWorkingDir(userId), resolveSessionSnapshot: (sessionId) => sessionSnapshotService.get(sessionId), registerPublicHtmlArtifactsForConversation: (payload) => registerPublicHtmlArtifactsForConversationPackage({ diff --git a/mindspace-workspace-sync.mjs b/mindspace-workspace-sync.mjs index 3b022f8..1a47a54 100644 --- a/mindspace-workspace-sync.mjs +++ b/mindspace-workspace-sync.mjs @@ -53,6 +53,26 @@ function workspaceAssetDownloadUrl(assetId) { return `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`; } +function scanOptionsForWorkspaceFile(categoryCode, filename, mimeType) { + const normalizedName = String(filename ?? '').toLowerCase(); + if ( + categoryCode === 'public' && + mimeType === 'text/html' && + normalizedName.endsWith('.html') + ) { + return { htmlActiveContentPolicy: 'sandbox_warn' }; + } + return {}; +} + +function assetStatusForScan(scan) { + return scan.scanStatus === 'blocked' ? 'quarantined' : 'ready'; +} + +function versionStatusForScan(scan) { + return scan.scanStatus === 'blocked' ? 'blocked' : scan.scanStatus; +} + export function normalizeWorkspaceRelativePath(relativePath) { const normalized = String(relativePath ?? '') .normalize('NFKC') @@ -180,9 +200,10 @@ export function createWorkspaceAssetSync({ const scan = runBasicFileScan(buffer, { filename: file.filename, mimeType: detectedMimeType, + ...scanOptionsForWorkspaceFile(category.category_code, file.filename, detectedMimeType), }); - const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; - const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const assetStatus = assetStatusForScan(scan); + const versionScanStatus = versionStatusForScan(scan); const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const assetId = idFactory(); const versionId = idFactory(); @@ -295,9 +316,10 @@ export function createWorkspaceAssetSync({ const scan = runBasicFileScan(buffer, { filename: file.filename, mimeType: detectedMimeType, + ...scanOptionsForWorkspaceFile(category.category_code, file.filename, detectedMimeType), }); - const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; - const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const assetStatus = assetStatusForScan(scan); + const versionScanStatus = versionStatusForScan(scan); const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const [versionRows] = await conn.query( `SELECT COALESCE(MAX(version_no), 0) AS max_version diff --git a/mindspace-workspace-sync.test.mjs b/mindspace-workspace-sync.test.mjs index 260a0c3..65f340f 100644 --- a/mindspace-workspace-sync.test.mjs +++ b/mindspace-workspace-sync.test.mjs @@ -112,10 +112,12 @@ test('syncUserWorkspace imports new workspace files into asset library', async ( user_id: params[1], category_id: params[3], original_filename: params[6], - checksum: params[10], - size_bytes: params[9], - current_version_id: params[8], - status: params[13], + checksum: params[11], + size_bytes: params[10], + current_version_id: params[9], + risk_level: params[12], + visibility: params[13], + status: params[14], source_type: 'workspace', }); return [[]]; @@ -203,6 +205,111 @@ test('syncUserWorkspace imports new workspace files into asset library', async ( ]); }); +test('syncUserWorkspace imports sandboxable public html as warned ready asset', async () => { + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-public-html-')); + const storageRoot = path.join(h5Root, 'data', 'mindspace'); + const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); + await fs.mkdir(path.join(workspace, 'public'), { recursive: true }); + await fs.writeFile( + path.join(workspace, 'public', 'dashboard.html'), + ` + + `, + ); + + const state = { + users: [{ id: 'user-1', username: 'john' }], + categories: [ + { id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }, + ], + spaces: [ + { + id: 'space-1', + user_id: 'user-1', + quota_bytes: 5 * 1024 * 1024, + used_bytes: 0, + reserved_bytes: 0, + status: 'active', + }, + ], + assets: [], + versions: [], + }; + + let nextId = 0; + const pool = { + async query(sql, params = []) { + if (sql.includes('SELECT username FROM h5_users')) { + return [[{ username: 'john' }]]; + } + if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) { + const category = state.categories.find( + (item) => item.user_id === params[0] && item.category_code === params[1], + ); + return [category ? [category] : []]; + } + if (sql.includes('FROM h5_assets')) return [[]]; + return [[]]; + }, + async getConnection() { + return { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + async query(sql, params = []) { + if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) { + return [[state.spaces[0]]]; + } + if (sql.includes('INSERT INTO h5_assets')) { + state.assets.push({ + id: params[0], + original_filename: params[6], + workspace_relative_path: params[8], + size_bytes: params[10], + checksum: params[11], + risk_level: params[12], + visibility: params[13], + status: params[14], + }); + return [[]]; + } + if (sql.includes('INSERT INTO h5_asset_versions')) { + state.versions.push({ + id: params[0], + asset_id: params[1], + storage_key: params[2], + scan_status: params[7], + }); + return [[]]; + } + if (sql.includes('used_bytes = used_bytes +')) { + state.spaces[0].used_bytes += params[0]; + return [[]]; + } + return pool.query(sql, params); + }, + }; + }, + }; + + const sync = createWorkspaceAssetSync({ + pool, + storageRoot, + h5Root, + maxFileBytes: 1024 * 1024, + idFactory: () => `id-${++nextId}`, + }); + + const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'public' }); + assert.equal(result.imported, 1); + assert.equal(state.assets[0].status, 'ready'); + assert.equal(state.assets[0].risk_level, 'medium'); + assert.equal(state.assets[0].visibility, 'public_candidate'); + assert.equal(state.versions[0].scan_status, 'warned'); + assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/public\/dashboard\.html$/); +}); + test('syncUserWorkspace imports nested workspace files into asset library', async () => { const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-nested-')); const storageRoot = path.join(h5Root, 'data', 'mindspace'); @@ -270,10 +377,12 @@ test('syncUserWorkspace imports nested workspace files into asset library', asyn user_id: params[1], category_id: params[3], original_filename: params[6], - checksum: params[10], - size_bytes: params[9], - current_version_id: params[8], - status: params[13], + checksum: params[11], + size_bytes: params[10], + current_version_id: params[9], + risk_level: params[12], + visibility: params[13], + status: params[14], source_type: 'workspace', }); return [[]]; diff --git a/scenarios/dev-logout.json b/scenarios/dev-logout.json new file mode 100644 index 0000000..61c5cad --- /dev/null +++ b/scenarios/dev-logout.json @@ -0,0 +1,19 @@ +{ + "id": "dev-logout", + "name": "DEV 登出清 Cookie", + "description": "登录 → POST /auth/logout → 验证 /auth/status 与 /api/me 拒绝旧 Cookie", + "account": { + "username": "john", + "password": "888888" + }, + "steps": [ + { + "action": "login", + "label": "登录 john 账户" + }, + { + "action": "logout", + "label": "登出并验证 Cookie 已清除" + } + ] +} diff --git a/scenarios/john2-suzhou-page.json b/scenarios/john2-suzhou-page.json new file mode 100644 index 0000000..2278986 --- /dev/null +++ b/scenarios/john2-suzhou-page.json @@ -0,0 +1,26 @@ +{ + "id": "john2-suzhou-page", + "name": "john2 苏州攻略新页", + "description": "新用户 john2 → 强制生成全新苏州 1 日攻略 HTML → 验证 run 成功与页面可访问", + "account": { + "username": "john2", + "password": "888888" + }, + "steps": [ + { + "action": "chat", + "label": "john2 登录并请求全新苏州页面", + "message": "请帮我做一个全新的苏州一日游攻略页面,保存为 public/suzhou-e2e-test.html,不要修改或复用已有页面,做完直接给我链接。", + "expect": { + "assistantMinChars": 20, + "timeoutMs": 600000, + "replyKeywords": ["苏州"], + "page": { + "keywords": ["苏州"], + "requirePublicLink": true, + "requireHttp200": true + } + } + } + ] +} diff --git a/scripts/run-scenario-test.mjs b/scripts/run-scenario-test.mjs index 3a792c1..bb9be25 100644 --- a/scripts/run-scenario-test.mjs +++ b/scripts/run-scenario-test.mjs @@ -14,6 +14,7 @@ import { listScenarios, loadScenario, loginViaApi, + logoutViaApi, resolvePortalBase, snapshotPublicHtml, verifyPageAccess, @@ -95,9 +96,22 @@ async function runScenario(scenario, port) { const label = step.label ?? step.action; console.log(`\n--- ${label} ---`); - if (step.action === 'login') { - auth = await loginViaApi(baseUrl, account, reporter); - publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username; + if (step.action === 'login' || (step.action === 'chat' && !auth && step.login !== false)) { + if (!auth) { + auth = await loginViaApi(baseUrl, account, reporter); + publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username; + } + if (step.action === 'login') { + continue; + } + } + + if (step.action === 'logout') { + if (!auth) { + throw new Error('logout 步骤前必须先 login'); + } + await logoutViaApi(baseUrl, auth.cookie, reporter); + auth = null; continue; } @@ -114,6 +128,7 @@ async function runScenario(scenario, port) { message: step.message, sessionId, selectedChatSkill: step.selectedChatSkill ?? null, + selectedAssetIds: step.selectedAssetIds ?? null, }); reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`); diff --git a/scripts/scenario-test-lib.mjs b/scripts/scenario-test-lib.mjs index 0c15450..ca67b19 100644 --- a/scripts/scenario-test-lib.mjs +++ b/scripts/scenario-test-lib.mjs @@ -75,6 +75,46 @@ export async function loginViaApi(baseUrl, { username, password }, reporter) { }; } +export async function logoutViaApi(baseUrl, cookie, reporter) { + const response = await fetch(`${baseUrl}/auth/logout`, { + method: 'POST', + headers: { Cookie: cookie }, + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(`登出失败 ${response.status}: ${JSON.stringify(body)}`); + } + + const setCookie = response.headers.getSetCookie?.() ?? []; + const cleared = setCookie.some((line) => /Max-Age=0/i.test(line) || /Expires=Thu, 01 Jan 1970/i.test(line)); + if (!cleared) { + reporter.fail('登出 Cookie', '响应未包含清 Cookie 的 Set-Cookie'); + } else { + reporter.pass('登出 Cookie', 'Set-Cookie 已清除会话'); + } + + const statusResponse = await fetch(`${baseUrl}/auth/status`, { + headers: { Cookie: cookie }, + }); + const statusBody = await statusResponse.json().catch(() => ({})); + if (statusBody?.authenticated) { + reporter.fail('登出状态', `/auth/status 仍为 authenticated`); + } else { + reporter.pass('登出状态', '未登录'); + } + + const protectedResponse = await fetch(`${baseUrl}/api/me`, { + headers: { Cookie: cookie }, + }); + if (protectedResponse.status === 401 || protectedResponse.status === 403) { + reporter.pass('登出后 API', `${protectedResponse.status} 已拒绝旧 Cookie`); + } else { + reporter.fail('登出后 API', `/api/me 返回 ${protectedResponse.status},旧 Cookie 仍有效`); + } + + return body; +} + function buildUserMessage(text, { selectedChatSkill = null } = {}) { const metadata = { userVisible: true, displayText: text }; if (selectedChatSkill) { @@ -88,13 +128,21 @@ function buildUserMessage(text, { selectedChatSkill = null } = {}) { }; } -export async function createAgentRun(baseUrl, cookie, { message, sessionId = null, selectedChatSkill = null }) { +export async function createAgentRun(baseUrl, cookie, { + message, + sessionId = null, + selectedChatSkill = null, + selectedAssetIds = null, +}) { const requestId = crypto.randomUUID(); const body = { request_id: requestId, user_message: buildUserMessage(message, { selectedChatSkill }), }; if (sessionId) body.session_id = sessionId; + if (Array.isArray(selectedAssetIds) && selectedAssetIds.length > 0) { + body.selected_asset_ids = selectedAssetIds; + } const response = await fetch(`${baseUrl}/api/agent/runs`, { method: 'POST', diff --git a/server.mjs b/server.mjs index 276f45f..364cea7 100644 --- a/server.mjs +++ b/server.mjs @@ -25,6 +25,7 @@ import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from '. import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs'; import { isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs'; import { + clearUserLogoutCookies, clearUserSessionCookie, createUserAuth, resolveCookieDomainForRequest, @@ -195,6 +196,7 @@ import { createPageDataService } from './page-data-service.mjs'; import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs'; import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs'; import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs'; +import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.mjs'; import { isNativeH5ApiPath } from './policies.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -424,6 +426,7 @@ async function bootstrapUserAuth() { maxFileBytes: mindSpaceServerRuntime.maxFileBytes, publicPageLimit: mindSpaceServerRuntime.publicPageLimit, resolveUserIdForAgentSession, + resolveWorkspaceRoot: (userId) => userAuth.resolveWorkingDir(userId), resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId), registerPublicHtmlArtifactsForConversation, syncWorkspaceAssetsEnabled: WORKSPACE_MAINTENANCE_ENABLED, @@ -793,10 +796,13 @@ function setUserLoginCookies(res, req, token) { } function clearUserLoginCookies(res, req) { - res.set( - 'Set-Cookie', - clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)), - ); + const secure = isSecureRequest(req); + const domain = resolveCookieDomainForRequest(req); + const cookies = clearUserLogoutCookies(secure, domain); + if (legacyAuth) { + cookies.push(clearSessionCookie(secure)); + } + res.set('Set-Cookie', cookies); } async function attachUserSession(req, _res, next) { @@ -1245,14 +1251,14 @@ app.get('/auth/me', async (req, res) => { app.post('/auth/logout', async (req, res) => { await userAuthReady; - const secure = isSecureRequest(req); if (userAuth) { await userAuth.revoke(userToken(req)); - clearUserLoginCookies(res, req); } if (legacyAuth) { legacyAuth.revoke(legacySessionToken(req)); - res.set('Set-Cookie', clearSessionCookie(secure)); + } + if (userAuth || legacyAuth) { + clearUserLoginCookies(res, req); } res.status(204).end(); }); @@ -1857,6 +1863,7 @@ app.use('/wiki-api', wikiApi); const api = express.Router(); api.use(jsonUnlessMultipart); +attachShenmeiOpinionFormRoutes(api, { rootDir: __dirname }); api.use(async (req, res, next) => { await userAuthReady; @@ -1864,6 +1871,7 @@ api.use(async (req, res, next) => { if (req.path.startsWith('/internal/agent/')) return next(); if (req.path === '/agent/mindspace_page_patch') return next(); if (req.path === '/agent/mindspace_asset_delete') return next(); + if (req.path === '/agent/mindspace_asset_download') return next(); if (req.path === '/config/blocked-words') return next(); if (req.method === 'GET' && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { return next(); @@ -4036,6 +4044,32 @@ api.post('/agent/mindspace_asset_delete', async (req, res) => { } }); +api.post('/agent/mindspace_asset_download', async (req, res) => { + if (!mindSpaceAssetAgent) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await mindSpaceAssetAgent.applyAgentDownload(req.body ?? {}); + await mindSpaceAudit?.write({ + userId: result.userId ?? null, + action: 'asset.download', + objectType: 'asset', + objectId: result.assetId, + ip: req.ip, + metadata: { via: 'agent', relativePath: result.relativePath ?? null }, + }); + return sendData(res, req, result); + } catch (error) { + if (error?.code === 'forbidden') { + return sendError(res, req, 403, error.code, error.message); + } + if (error?.code === 'invalid_request' || error?.code === 'asset_not_found') { + return sendError(res, req, 400, error.code, error.message); + } + return mindSpaceError(res, req, error); + } +}); + api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { @@ -4684,7 +4718,7 @@ api.post('/agent/runs', async (req, res, next) => { [ tkmindProxy.requireUser, tkmindProxy.ensureChatAllowed, - createPostAgentRunsHandler({ userAuth, sessionAccess, agentRunGateway }), + createPostAgentRunsHandler({ userAuth, sessionAccess, agentRunGateway, mindSpaceAssetAgent }), ], req, res, diff --git a/skills/web/SKILL.md b/skills/web/SKILL.md index 057c3c6..375b537 100644 --- a/skills/web/SKILL.md +++ b/skills/web/SKILL.md @@ -30,5 +30,6 @@ description: 网页抓取与搜索技能:访问网页、查阅文档、搜索 ## 国内网络环境(建议) - 生产环境访问不了 `google.com`,优先用本技能的 `web_search`(DuckDuckGo)/`fetch_url`,避免直接拿 `computercontroller__web_scrape` 抓 Google 页面来回重试 -- `web_search` 连续几次没有可用结果时,改用 `fetch_url` 直接访问 `https://cn.bing.com/search?q=...` 或 `https://www.so.com/s?q=...` 这类国内可达的搜索入口 +- **`web_search` 最多 2 次**(可换关键词);若仍无可用结果,**再 1 次**用 `fetch_url` 访问 `https://cn.bing.com/search?q=...` 或 `https://www.so.com/s?q=...` +- **3 轮搜索后仍无结果**:停止搜索,用内置知识直接生成页面/回答,并说明未获取实时搜索结果 - 百度/知乎/大众点评等站点有反爬拦截,遇到跳转或空结果就换个搜索源,不必在同一个来源上反复硬抓 diff --git a/src/api/client.ts b/src/api/client.ts index b6e723a..b8ec14d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2534,8 +2534,7 @@ export async function testLlmProviderKey( } export async function logout(): Promise { - if (import.meta.env.DEV) return; - await fetch('/auth/logout', { method: 'POST' }); + await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' }); } export async function resumeSession( @@ -2658,6 +2657,9 @@ export async function createAgentRun( ...(options.toolMode ? { tool_mode: options.toolMode } : {}), ...(options.taskType ? { task_type: options.taskType } : {}), ...(options.forceDeepReasoning ? { force_deep_reasoning: true } : {}), + ...(options.selectedAssetIds?.length + ? { selected_asset_ids: options.selectedAssetIds } + : {}), }), }, { timeoutMs: AGENT_CONNECT_TIMEOUT_MS }, diff --git a/src/hooks/usePageEditSubChat.ts b/src/hooks/usePageEditSubChat.ts index 69afde6..3064ed6 100644 --- a/src/hooks/usePageEditSubChat.ts +++ b/src/hooks/usePageEditSubChat.ts @@ -344,21 +344,31 @@ export function usePageEditSubChat({ setPendingTool(null); try { + const runOptions = resolveAgentRunOptions(trimmed, { + taskType: 'page_edit_code_task', + forceCode: true, + userId: user?.id ?? null, + requestId, + mindspaceContext: context, + pageEdit: { + pageId, + pageTitle, + }, + }); const createdRun = await createAgentRun( currentSession.id, requestId, userMessage, - resolveAgentRunOptions(trimmed, { - taskType: 'page_edit_code_task', - forceCode: true, - userId: user?.id ?? null, - requestId, - mindspaceContext: context, - pageEdit: { - pageId, - pageTitle, - }, - }), + { + ...runOptions, + ...(context.selectedAssets?.length + ? { + selectedAssetIds: context.selectedAssets + .map((asset) => asset.id) + .filter(Boolean), + } + : {}), + }, ); const finishedRun = createdRun.status === 'succeeded' ? createdRun : await waitForAgentRun(createdRun.id); diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 7adda6a..a1785ae 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -8,6 +8,7 @@ import { confirmTool, createAgentRun, deleteChatSession, + getAgentRun, getMindSpace, getMe, listNotifications, @@ -108,6 +109,7 @@ async function waitForAgentRun(runId: string): Promise { } const DIRECT_CHAT_SESSION_POLL_MS = 600; +const AGENT_RUN_WAIT_TIMEOUT_MS = 16 * 60 * 1000; async function waitForAgentRunWithDirectChatPreview( runId: string, @@ -119,7 +121,22 @@ async function waitForAgentRunWithDirectChatPreview( ): Promise { return await new Promise((resolve, reject) => { let pollTimer: number | null = null; + let waitTimer: number | null = null; let pollingSessionId: string | null = null; + let settled = false; + let unsubscribe = () => {}; + + const settle = (action: () => void) => { + if (settled) return; + settled = true; + stopPoll(); + if (waitTimer != null) { + window.clearTimeout(waitTimer); + waitTimer = null; + } + unsubscribe(); + action(); + }; const stopPoll = () => { if (pollTimer != null) { @@ -150,7 +167,7 @@ async function waitForAgentRunWithDirectChatPreview( }, DIRECT_CHAT_SESSION_POLL_MS); }; - const unsubscribe = subscribeAgentRunEvents( + unsubscribe = subscribeAgentRunEvents( runId, (run) => { if (run.sessionId) { @@ -160,23 +177,41 @@ async function waitForAgentRunWithDirectChatPreview( } } if (run.status === 'succeeded') { - stopPoll(); - unsubscribe(); - resolve(run); + settle(() => resolve(run)); return; } if (run.status === 'failed') { - stopPoll(); - unsubscribe(); - reject(new Error(run.error || '后台任务失败,请稍后重试')); + settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试'))); } }, (error) => { - stopPoll(); - unsubscribe(); - reject(error); + settle(() => reject(error)); }, ); + + waitTimer = window.setTimeout(() => { + void getAgentRun(runId) + .then((run) => { + if (run.status === 'succeeded') { + settle(() => resolve(run)); + return; + } + if (run.status === 'failed') { + settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试'))); + return; + } + settle(() => + reject( + new Error('后台任务耗时较长,已停止等待;页面若已生成可在「我的页面」查看'), + ), + ); + }) + .catch((error) => { + settle(() => + reject(error instanceof Error ? error : new Error(String(error))), + ); + }); + }, AGENT_RUN_WAIT_TIMEOUT_MS); }); } @@ -1347,6 +1382,13 @@ export function useTKMindChat( { ...runOptions, ...(options?.forceDeepReasoning ? { forceDeepReasoning: true } : {}), + ...(options?.mindspaceContext?.selectedAssets?.length + ? { + selectedAssetIds: options.mindspaceContext.selectedAssets + .map((asset) => asset.id) + .filter(Boolean), + } + : {}), }, ); const runSessionId = createdRun.sessionId ?? activeSessionId; diff --git a/src/utils/agentRunMode.ts b/src/utils/agentRunMode.ts index 182266d..6061474 100644 --- a/src/utils/agentRunMode.ts +++ b/src/utils/agentRunMode.ts @@ -6,6 +6,7 @@ export type AgentRunCreateOptions = { forceDeepReasoning?: boolean; validation?: AgentRunValidation | null; validationInstruction?: string | null; + selectedAssetIds?: string[]; }; export type AgentRunValidationFile = { diff --git a/user-auth.mjs b/user-auth.mjs index f54b4d7..8b7e3b8 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -2973,6 +2973,15 @@ export function clearUserSessionCookie(secure, domain = resolveCookieDomain()) { return buildUserSessionCookie('', secure, { domain, maxAge: 0 }); } +/** Mirror userLoginCookies: clear shared-domain and legacy host-only session cookies. */ +export function clearUserLogoutCookies(secure, domain = resolveCookieDomain()) { + const cookies = [clearUserSessionCookie(secure, domain)]; + if (domain) { + cookies.push(clearUserSessionCookie(secure, null)); + } + return cookies; +} + /** Set shared-domain session and drop legacy host-only cookie from before H5_COOKIE_DOMAIN. */ export function userLoginCookies(token, secure, domain = resolveCookieDomain()) { const cookies = [userSessionCookie(token, secure, domain)]; diff --git a/user-auth.test.mjs b/user-auth.test.mjs index 6a7db89..fb1edb1 100644 --- a/user-auth.test.mjs +++ b/user-auth.test.mjs @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from '@node-rs/argon2'; -import { createUserAuth } from './user-auth.mjs'; +import { createUserAuth, clearUserLogoutCookies, userLoginCookies } from './user-auth.mjs'; function hashPasswordPbkdf2(password, salt) { return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex'); @@ -728,3 +728,22 @@ test('upsertWechatAgentRoute uses atomic insert-on-duplicate semantics', async ( assert.match(calls[0][0], /ON DUPLICATE KEY UPDATE/); assert.equal(calls.some(([sql]) => /UPDATE h5_wechat_agent_routes/.test(sql)), false); }); + +test('clearUserLogoutCookies clears shared-domain and host-only cookies', () => { + const cookies = clearUserLogoutCookies(false, '.tkmind.cn'); + assert.equal(cookies.length, 2); + assert.match(cookies[0], /tkmind_user_session=/); + assert.match(cookies[0], /Domain=\.tkmind\.cn/); + assert.match(cookies[0], /Max-Age=0/); + assert.match(cookies[1], /Max-Age=0/); + assert.doesNotMatch(cookies[1], /Domain=/); +}); + +test('userLoginCookies and clearUserLogoutCookies are symmetric for shared domain', () => { + const login = userLoginCookies('token-abc', true, '.tkmind.cn'); + const logout = clearUserLogoutCookies(true, '.tkmind.cn'); + assert.equal(login.length, 2); + assert.equal(logout.length, 2); + assert.match(login[1], /Max-Age=0/); + assert.match(logout[1], /Max-Age=0/); +}); diff --git a/user-publish.mjs b/user-publish.mjs index a700d9c..31ae257 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -358,6 +358,14 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools '- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段;按本会话给出的公网前缀模板拼接真实地址', '- 按需下载附件:Word 用 `generate_docx` 写入 `public/*.docx`,相对链接文件必须已在 `public/` 落盘,basename 与 HTML 一致', '- 按需长图附件:用 `generate_long_image` 写入 `public/*.long.png`,不要把 `.thumbnail.svg` 当成长图', + '', + '## sandbox-fs / MCP 稳定性', + '- `private_data_bind_workspace_page` 等 sandbox-fs 工具若返回 **Transport closed**,最多再试 **1 次**', + '- 连续失败后:若 `public/*.html` 已 write_file 落盘,改走 Portal/H5 API 或 shell 发布兜底;**禁止**反复 kill MCP 或 curl 401 的浏览器下载接口', + '', + '## 网页搜索上限', + '- `web_search` 最多 **2 次**(可换关键词);再 **1 次** `fetch_url` 访问 Bing/360 搜索页', + '- 3 轮仍无可用结果:停止搜索,用内置知识直接生成页面,并告知用户未获取实时搜索结果', ); return lines.join('\n'); } @@ -372,6 +380,8 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish `- 当前用户 **${addressName}** 的 Agent 工作区(唯一文件根目录):\`${publishDir}\``, '- 用户上传落在分区子目录:`oa/`、`private/`、`public/`(均在上述工作区内)', `- 公网 HTML 前缀(公开区页面):\`${buildPublicUrl(publicBaseUrl, slug, `${PUBLIC_ZONE_DIR}/`)}\``, + '- **勾选 OA 资料**:优先读工作区 `oa/文件名`;**禁止** curl 浏览器登录接口 `/api/mindspace/v1/assets/:id/download`(Agent 无 cookie 会 401)', + '- 工作区无副本时:POST `/api/agent/mindspace_asset_download`(body: session_id + asset_id)落盘后再 `read_file`', '- **查找文件**:在工作区内对应分区搜索(如 `oa/2025-12-06T13-34_export.csv`),不要搜其它用户目录或公网 URL', '- **禁止**:访问 `assets/` 内部路径、其它用户目录、主机绝对路径;禁止用公网 URL 列目录或读 CSV', '- **路径规则**:只用相对路径;禁止 `../`;工作区外的路径会被系统拒绝(OS 层强制,非软约束)', @@ -388,6 +398,8 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish '- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`,按模板拼真实地址', '- 按需下载链接(如 `report.docx`)必须与 HTML 同目录且文件名一致;Word 必须用 sandbox-fs `generate_docx` 生成,不要用 `computercontroller` / shell 作为交付依据', '- 按需长图链接:生成 `report.long.png` 后给 `[长图预览](.../report.long.png)`,下载用 `[下载长图](.../report.html?download=long-image)`', + '- **sandbox-fs Transport closed**:`private_data_bind_workspace_page` 等 MCP 工具若返回 Transport closed,**最多重试 1 次**;仍失败则改走 Portal API / shell 发布兜底,**禁止**无限 kill/restart MCP', + '- **网页搜索**:`web_search` 最多 2 次;再试 1 次 Bing/360 `fetch_url`;仍无结果则用内置知识直接生成页面并说明未获取实时搜索结果', `- 发布技能:\`${PUBLISH_SKILL_NAME}\`(生成页面前应 load_skill)`, ].join('\n'); }