diff --git a/chat-agent-run-gate.test.mjs b/chat-agent-run-gate.test.mjs index 51a0e85..46f6682 100644 --- a/chat-agent-run-gate.test.mjs +++ b/chat-agent-run-gate.test.mjs @@ -36,6 +36,17 @@ test('resolvePostAgentRunChatState idles after worker-side agent run success', ( ); }); +test('completed run wins when the direct-chat snapshot is not ready', () => { + assert.equal( + resolvePostAgentRunChatState({ + chatState: 'streaming', + finishedViaPortalDirectChat: false, + agentRunSucceeded: true, + }), + 'idle', + ); +}); + test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () => { assert.equal(shouldPromoteSessionIdToStreaming('idle'), false); assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true); diff --git a/db.mjs b/db.mjs index 7a72329..a6f3c52 100644 --- a/db.mjs +++ b/db.mjs @@ -125,6 +125,27 @@ export async function ensureAssetGatewaySchema(pool) { `); } +/** Additive only: does not read, move, or mutate user page/data records. */ +export async function ensurePageDeliveryContractSchema(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_page_delivery_contracts ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + request_id VARCHAR(128) NOT NULL, + workspace_relative_path VARCHAR(512) NOT NULL, + data_mode ENUM('static', 'pg_required') NOT NULL DEFAULT 'static', + status ENUM('preparing', 'ready', 'failed') NOT NULL DEFAULT 'preparing', + failure_reason VARCHAR(1000) NULL, + created_at BIGINT NOT NULL, + ready_at BIGINT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_delivery_contract_request_path (user_id, request_id, workspace_relative_path), + KEY idx_h5_delivery_contract_route (user_id, workspace_relative_path, updated_at), + CONSTRAINT fk_h5_delivery_contract_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + export async function migrateSchema(pool) { const renames = [ ['h5_user_sessions', 'goose_session_id', 'agent_session_id'], @@ -249,6 +270,7 @@ export async function migrateSchema(pool) { // Optional asset capability control plane. These rows configure no runtime // worker by themselves; the existing chat/page path remains independent. await ensureAssetGatewaySchema(pool); + await ensurePageDeliveryContractSchema(pool); // Shared experience store (etat C). Keep in sync with schema.sql. await pool.query(` diff --git a/mindspace-delivery-contract.mjs b/mindspace-delivery-contract.mjs new file mode 100644 index 0000000..2917b3b --- /dev/null +++ b/mindspace-delivery-contract.mjs @@ -0,0 +1,48 @@ +import crypto from 'node:crypto'; + +export function normalizeDeliveryRelativePath(value) { + const path = String(value ?? '').replace(/\\/g, '/').replace(/^\/+/, ''); + if (!path.startsWith('public/') || !path.toLowerCase().endsWith('.html')) return null; + if (path.split('/').some((part) => !part || part === '.' || part === '..')) return null; + return path; +} + +export async function preparePageDeliveryContract({ pool, userId, requestId, relativePath, pgRequired = false }) { + const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath); + if (!pool || !userId || !requestId || !workspaceRelativePath) return null; + const now = Date.now(); + const id = crypto.randomUUID(); + await pool.query( + `INSERT INTO h5_page_delivery_contracts + (id, user_id, request_id, workspace_relative_path, data_mode, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?) + ON DUPLICATE KEY UPDATE data_mode = VALUES(data_mode), status = 'preparing', failure_reason = NULL, updated_at = VALUES(updated_at)`, + [id, userId, requestId, workspaceRelativePath, pgRequired ? 'pg_required' : 'static', now, now], + ); + return { id, userId, requestId, workspaceRelativePath, dataMode: pgRequired ? 'pg_required' : 'static', status: 'preparing' }; +} + +export async function getPageDeliveryContract({ pool, userId, relativePath }) { + const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath); + if (!pool || !userId || !workspaceRelativePath) return null; + const [rows] = await pool.query( + `SELECT id, data_mode, status, failure_reason FROM h5_page_delivery_contracts + WHERE user_id = ? AND workspace_relative_path = ? + ORDER BY updated_at DESC LIMIT 1`, + [userId, workspaceRelativePath], + ); + return rows?.[0] ?? null; +} + +export async function markPageDeliveryContractReady({ pool, userId, relativePath }) { + const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath); + if (!pool || !userId || !workspaceRelativePath) return false; + const now = Date.now(); + const [result] = await pool.query( + `UPDATE h5_page_delivery_contracts + SET status = 'ready', ready_at = ?, failure_reason = NULL, updated_at = ? + WHERE user_id = ? AND workspace_relative_path = ? AND status = 'preparing'`, + [now, now, userId, workspaceRelativePath], + ); + return Number(result?.affectedRows ?? 0) > 0; +} diff --git a/mindspace-delivery-contract.test.mjs b/mindspace-delivery-contract.test.mjs new file mode 100644 index 0000000..6c8bf39 --- /dev/null +++ b/mindspace-delivery-contract.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + getPageDeliveryContract, + markPageDeliveryContractReady, + normalizeDeliveryRelativePath, + preparePageDeliveryContract, +} from './mindspace-delivery-contract.mjs'; + +test('normalizes only safe public HTML delivery paths', () => { + assert.equal(normalizeDeliveryRelativePath('public/survey.html'), 'public/survey.html'); + assert.equal(normalizeDeliveryRelativePath('/public/nested/report.html'), 'public/nested/report.html'); + assert.equal(normalizeDeliveryRelativePath('public/../secret.html'), null); + assert.equal(normalizeDeliveryRelativePath('public/report.js'), null); +}); + +test('contract lifecycle writes preparing then ready against the same route key', async () => { + const calls = []; + const pool = { + async query(sql, params) { + calls.push({ sql, params }); + if (sql.includes('SELECT id, data_mode')) return [[{ id: 'contract-1', data_mode: 'pg_required', status: 'preparing' }]]; + if (sql.includes('UPDATE h5_page_delivery_contracts')) return [{ affectedRows: 1 }]; + return [{ affectedRows: 1 }]; + }, + }; + const contract = await preparePageDeliveryContract({ + pool, + userId: 'user-1', + requestId: 'request-1', + relativePath: 'public/form.html', + pgRequired: true, + }); + assert.equal(contract.status, 'preparing'); + assert.equal(contract.dataMode, 'pg_required'); + const prepareCall = calls.find((call) => call.sql.includes('INSERT INTO h5_page_delivery_contracts')); + assert.equal(prepareCall.params[4], 'pg_required'); + const current = await getPageDeliveryContract({ pool, userId: 'user-1', relativePath: 'public/form.html' }); + assert.equal(current.status, 'preparing'); + assert.equal(await markPageDeliveryContractReady({ pool, userId: 'user-1', relativePath: 'public/form.html' }), true); + assert.ok(calls.some((call) => call.sql.includes("status = 'ready'"))); +}); diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index e7cb29c..41c7071 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -31,6 +31,16 @@ const LOCAL_STORAGE_FALLBACK_HINT_PATTERN = /fallback\s*到\s*localStorage|local const repairAttemptsBySession = new Map(); +/** + * Explicit user choice wins over fallible text-intent classification. The UI + * records this on the original user message before any agent execution starts. + */ +function isPgRequiredByMessage(messages = []) { + return messages.some( + (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, + ); +} + function envFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); if (!raw) return fallback; @@ -324,7 +334,8 @@ export function evaluatePageDataFinishGuard({ requestStartedAt = 0, } = {}) { const resolvedAgentText = resolvePageDataGuardAgentText({ agentText, messages }); - const pageDataIntent = isPageDataIntent(resolvedAgentText); + const pgRequired = isPgRequiredByMessage(messages); + const pageDataIntent = pgRequired || isPageDataIntent(resolvedAgentText); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); const recentBinds = extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt }); @@ -359,10 +370,11 @@ export function evaluatePageDataFinishGuard({ unboundFiles.length > 0 || (relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && - usedPageDataCollectSkill(messages))); + (usedPageDataCollectSkill(messages) || pgRequired))); return { pageDataIntent, + pgRequired, structuralPageData: structuralFiles.length > 0, relevantFiles, htmlIssues, @@ -396,13 +408,13 @@ export async function evaluatePageDataFinishGuardAsync({ findPageByRelativePath, }); const needsRepair = - base.structuralPageData && + (base.structuralPageData || base.pgRequired) && (base.htmlIssues.length > 0 || unboundFiles.length > 0 || (base.pageDataIntent && base.relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && - usedPageDataCollectSkill(messages))); + (usedPageDataCollectSkill(messages) || base.pgRequired))); return { ...base, @@ -425,7 +437,7 @@ export function buildPageDataCollectRepairPrompt({ unboundFiles = [], } = {}) { const lines = [ - '【系统补绑请求】检测到 Page Data 问卷/数据页交付不完整。请立即按 page-data-collect 技能修复:', + '【系统交付门禁】本轮用户已要求使用专属 PostgreSQL 数据空间,但 Page Data 页面交付尚未验证完成。请立即按 page-data-collect 技能修复:', '1. load_skill → page-data-collect', '2. 确保 public/*.html 引入 /assets/page-data-client.js,且 JS 使用 MindSpacePageData.createClient({ apiBase: "/api" })', '3. 禁止 /api/page-data/ 旁路、localStorage 或自建后端', diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs index 7735b40..d99f886 100644 --- a/mindspace-page-data-finish-guard.test.mjs +++ b/mindspace-page-data-finish-guard.test.mjs @@ -116,6 +116,27 @@ test('evaluatePageDataFinishGuard detects unbound page data html', () => { } }); +test('explicit PG choice enables the delivery gate without relying on intent text', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-explicit-pg-')); + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + const evaluation = evaluatePageDataFinishGuard({ + publishDir, + agentText: '做一个很好看的介绍页面', + messages: [{ + role: 'user', + metadata: { memindRun: { pgRequired: true } }, + content: [{ type: 'text', text: '做一个很好看的介绍页面' }], + }], + }); + assert.equal(evaluation.pgRequired, true); + assert.equal(evaluation.pageDataIntent, true); + assert.equal(evaluation.needsRepair, true); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + test('finish guard blocks localStorage ledger generated from ordinary user language', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-ledger-')); const text = '帮我做一个家庭记账页面,我每天可以记录日期、收入、支出、分类和备注,日期默认当天。再做一个管理页面,可以查看所有记录和收支汇总。'; diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs index 3e340e2..1dca9cc 100644 --- a/mindspace-public-finish-sync.mjs +++ b/mindspace-public-finish-sync.mjs @@ -429,6 +429,19 @@ export function materializePublicHtmlWritesFromSessionEvent( return { materialized: [], skipped: [] }; } +/** Read the public HTML targets from a stream event without writing them. */ +export function collectPublicHtmlWritePathsFromSessionEvent(event, { publishDir, recentCount = 20 } = {}) { + if (!event || !publishDir) return []; + const messages = event.type === 'Message' && event.message + ? [event.message] + : event.type === 'UpdateConversation' && Array.isArray(event.conversation) + ? event.conversation.slice(-Math.max(1, recentCount)) + : []; + return [...new Set(extractPublicHtmlWriteArtifacts(messages, { publishDir }) + .map((artifact) => artifact.relativePath) + .filter(Boolean))]; +} + function messageHasPublicHtmlToolRequest(message, { publishDir = null } = {}) { if (extractPublicHtmlWriteArtifacts([message], { publishDir }).length > 0) { return true; diff --git a/schema.sql b/schema.sql index 3f7a20a..c86f99d 100644 --- a/schema.sql +++ b/schema.sql @@ -171,6 +171,25 @@ CREATE TABLE IF NOT EXISTS h5_page_records ( CONSTRAINT fk_h5_page_cover_asset FOREIGN KEY (cover_image_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Delivery contracts are separate from page/publication lifecycle records. +-- A contract gates only new agent-delivered workspace files; historical public +-- files without a contract remain accessible for backward compatibility. +CREATE TABLE IF NOT EXISTS h5_page_delivery_contracts ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + request_id VARCHAR(128) NOT NULL, + workspace_relative_path VARCHAR(512) NOT NULL, + data_mode ENUM('static', 'pg_required') NOT NULL DEFAULT 'static', + status ENUM('preparing', 'ready', 'failed') NOT NULL DEFAULT 'preparing', + failure_reason VARCHAR(1000) NULL, + created_at BIGINT NOT NULL, + ready_at BIGINT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_delivery_contract_request_path (user_id, request_id, workspace_relative_path), + KEY idx_h5_delivery_contract_route (user_id, workspace_relative_path, updated_at), + CONSTRAINT fk_h5_delivery_contract_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS h5_page_versions ( id CHAR(36) PRIMARY KEY, page_id CHAR(36) NOT NULL, diff --git a/server.mjs b/server.mjs index 0a58eb8..eecada0 100644 --- a/server.mjs +++ b/server.mjs @@ -131,10 +131,12 @@ import { resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; import { + collectPublicHtmlWritePathsFromSessionEvent, materializePublicHtmlWritesFromSessionEvent, normalizePublicHtmlRelativePath, syncPublicHtmlAfterFinish, } from './mindspace-public-finish-sync.mjs'; +import { getPageDeliveryContract, markPageDeliveryContractReady, preparePageDeliveryContract } from './mindspace-delivery-contract.mjs'; import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs'; import { maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs'; import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs'; @@ -5075,7 +5077,35 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { return sendDirectChatSessionEvents(req, res, portalDirectSnapshot); } const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id }); + // `proxySessionEvents` deliberately invokes `onEvent` synchronously so an + // async callback here would leave rejected database writes unhandled. + // Retain every in-flight contract write and await it before marking files + // deliverable after Finish. + const deliveryContractWrites = new Map(); const syncPublicHtmlDuringStream = (event) => { + const paths = collectPublicHtmlWritePathsFromSessionEvent(event, { publishDir }); + const eventMessages = event?.type === 'Message' && event.message + ? [event.message] + : event?.type === 'UpdateConversation' && Array.isArray(event.conversation) + ? event.conversation + : []; + const pgRequired = eventMessages.some( + (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, + ); + for (const relativePath of paths) { + if (deliveryContractWrites.has(relativePath)) continue; + const write = preparePageDeliveryContract({ + pool: authPool, + userId: req.currentUser.id, + requestId: sid, + relativePath, + pgRequired, + }).catch((error) => { + console.warn(`[MindSpace] failed to prepare delivery contract for ${relativePath}: ${error?.message || error}`); + return null; + }); + deliveryContractWrites.set(relativePath, write); + } materializePublicHtmlWritesFromSessionEvent(event, { publishDir }); }; // After Finish, refresh the snapshot and persist any newly generated public @@ -5131,7 +5161,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { `[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`, ); } - await maybeRepairH5HtmlAfterFinish({ + const htmlDelivery = await maybeRepairH5HtmlAfterFinish({ sessionId: sid, userId: uid, currentUser: req.currentUser, @@ -5154,7 +5184,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { .join('\n') : ''; await syncUserGeneratedPages(uid, { sessionId: sid }); - await maybeRepairPageDataAfterFinish({ + const pageDataDelivery = await maybeRepairPageDataAfterFinish({ sessionId: sid, userId: uid, publishDir, @@ -5165,6 +5195,40 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { tkmindProxy, userText: lastUserText, }); + const htmlReady = htmlDelivery?.skipped === 'ok'; + const pageDataReady = ['ok', 'not_page_data'].includes(String(pageDataDelivery?.skipped ?? '')); + if (htmlReady && pageDataReady) { + const publicHtmlRelativePaths = syncResult?.publicHtmlRelativePaths ?? []; + const pgRequired = [...(Array.isArray(messages) ? messages : [])].some( + (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, + ); + for (const relativePath of publicHtmlRelativePaths) { + // A Finish-only write may not have reached the stream callback. This + // also upgrades an early partial stream contract with the definitive + // user delivery choice before it becomes ready. + await (deliveryContractWrites.get(relativePath) ?? preparePageDeliveryContract({ + pool: authPool, + userId: uid, + requestId: sid, + relativePath, + pgRequired, + })); + if (deliveryContractWrites.has(relativePath)) { + await preparePageDeliveryContract({ + pool: authPool, + userId: uid, + requestId: sid, + relativePath, + pgRequired, + }); + } + await markPageDeliveryContractReady({ + pool: authPool, + userId: uid, + relativePath, + }).catch(() => false); + } + } if (lastUserMessage && memoryV2?.observePersonalMemory) { await memoryV2.observePersonalMemory({ userId: uid, @@ -6174,6 +6238,18 @@ async function serveUserPublishFile(req, res, next) { // On-demand cover: rasterize .thumbnail.svg → .thumbnail.png the first time a // forwarded link's og:image is fetched (and refresh it when the SVG changes). const resolvedPath = result.filePath; + if (authPool && result.ownerKey && /\.html$/i.test(resolvedPath)) { + const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: result.ownerKey }); + const relativePath = path.relative(publishDir, resolvedPath).replace(/\\/g, '/'); + const contract = await getPageDeliveryContract({ + pool: authPool, + userId: result.ownerKey, + relativePath, + }).catch(() => null); + if (contract && contract.status !== 'ready') { + return res.status(409).type('text/plain; charset=utf-8').send('页面已生成,正在完成发布验证,请稍后重试。'); + } + } if (/\.thumbnail\.png$/i.test(resolvedPath)) { const svgSibling = resolvedPath.replace(/\.png$/i, '.svg'); if (fs.existsSync(svgSibling)) { diff --git a/src/api/client.ts b/src/api/client.ts index b8ec14d..6f4528c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2131,6 +2131,10 @@ export async function listNotifications(status = 'unread', limit = 20): Promise< if (status && status !== 'all') params.set('status', status); params.set('limit', String(limit)); const response = await fetch(`/auth/notifications?${params}`); + if (response.status === 401) { + notifyUnauthorized(); + throw new ApiError(401, '未授权,请重新登录'); + } if (!response.ok) { throw new ApiError(response.status, '读取通知失败'); } diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 6c9b2c2..ac805c2 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,4 +1,5 @@ import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react'; +import { BrainCircuit, Database } from 'lucide-react'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; @@ -184,6 +185,7 @@ export function ChatPanel({ options?: { messageId?: string; forceDeepReasoning?: boolean; + pgRequired?: boolean; selectedChatSkill?: string; fileAttachments?: ChatFileAttachment[]; }, @@ -216,6 +218,8 @@ export function ChatPanel({ const [plazaPublishSource, setPlazaPublishSource] = useState(null); const [voiceNotice, setVoiceNotice] = useState(null); const [forceDeepReasoning, setForceDeepReasoning] = useState(false); + const [pgRequired, setPgRequired] = useState(false); + const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null); const [pendingImages, setPendingImages] = useState([]); const [pendingFiles, setPendingFiles] = useState([]); const [uploadingImage, setUploadingImage] = useState(false); @@ -293,6 +297,16 @@ export function ChatPanel({ nearBottomRef.current = true; }, [session?.id]); + useEffect(() => { + setChatControlOnboardingStep(0); + const timers = [ + window.setTimeout(() => setChatControlOnboardingStep(1), 2_200), + window.setTimeout(() => setChatControlOnboardingStep(2), 4_400), + window.setTimeout(() => setChatControlOnboardingStep(null), 6_600), + ]; + return () => timers.forEach((timer) => window.clearTimeout(timer)); + }, [session?.id]); + useLayoutEffect(() => { const container = mainRef.current; if (!container) return; @@ -730,6 +744,7 @@ export function ChatPanel({ await onSubmit(trimmed, imagesToSend, previewImagesToSend, { messageId: outgoingMessageId, forceDeepReasoning, + pgRequired, selectedChatSkill, fileAttachments: fileAttachmentsToSend, }); @@ -758,6 +773,7 @@ export function ChatPanel({ suppressVoiceUpdateRef.current = false; }, [ forceDeepReasoning, + pgRequired, input, onSubmit, onUploadFile, @@ -1189,6 +1205,7 @@ export function ChatPanel({ { pendingSkillRef.current = skillId ?? null; @@ -1196,23 +1213,38 @@ export function ChatPanel({ }} /> )} - {!showHomeWelcome && ( - - )} + + {chatState === 'streaming' ? ( {open && ( diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index b63d097..b7179f4 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -547,6 +547,7 @@ export function ChatView({ { messageId: options?.messageId, forceDeepReasoning: options?.forceDeepReasoning, + pgRequired: options?.pgRequired, selectedChatSkill: options?.selectedChatSkill, fileAttachments: options?.fileAttachments, }, diff --git a/src/components/SpaceChatPanel.tsx b/src/components/SpaceChatPanel.tsx index 3c3f590..9b5b37e 100644 --- a/src/components/SpaceChatPanel.tsx +++ b/src/components/SpaceChatPanel.tsx @@ -147,6 +147,7 @@ export function SpaceChatPanel({ ...context, messageId: options?.messageId, forceDeepReasoning: options?.forceDeepReasoning, + pgRequired: options?.pgRequired, fileAttachments: options?.fileAttachments, }, imageUrls, @@ -158,6 +159,7 @@ export function SpaceChatPanel({ mindspaceContext: context, messageId: options?.messageId, forceDeepReasoning: options?.forceDeepReasoning, + pgRequired: options?.pgRequired, fileAttachments: options?.fileAttachments, }, imageUrls, diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 55c18c1..e88be94 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -279,6 +279,10 @@ function isTransientConnectError(err: unknown) { return /超时|timeout/i.test(err.message); } +function isMissingAgentSessionError(err: unknown) { + return err instanceof ApiError && /session not found/i.test(err.message); +} + async function withTransientConnectRetry(fn: () => Promise, attempts = 3): Promise { let lastErr: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { @@ -329,6 +333,7 @@ export function useTKMindChat( const subscribedSessionIdRef = useRef(null); const unsubscribeRef = useRef<(() => void) | null>(null); const connectTokenRef = useRef(0); + const unavailableAgentSessionIdsRef = useRef(new Set()); const messagesRef = useRef([]); const sessionRef = useRef(null); const sessionsRef = useRef([]); @@ -1139,7 +1144,9 @@ export function useTKMindChat( }), ); const resumedPromise = - options?.skipResume || isDirectChatSessionId(sessionId) + options?.skipResume || + isDirectChatSessionId(sessionId) || + unavailableAgentSessionIdsRef.current.has(sessionId) ? Promise.resolve(options.seedSession ?? null) : withTransientConnectRetry(() => resumeSession(sessionId, { @@ -1160,7 +1167,16 @@ export function useTKMindChat( setMessageHistoryHasMore(messageHistoryHasMoreRef.current); setMessageHistoryTotal(messageHistoryTotalRef.current); setMessages(history); - const resumed = await resumedPromise; + let resumed: Session | null = null; + try { + resumed = await resumedPromise; + } catch (err) { + if (!isMissingAgentSessionError(err)) throw err; + // Persisted conversation is still readable even when its transient + // Agent runtime has been reclaimed. Do not leave a completed chat + // in the connecting state or retry resume on future selections. + unavailableAgentSessionIdsRef.current.add(sessionId); + } if (token !== connectTokenRef.current) return; setSession({ ...detail, ...(resumed ?? {}), id: sessionId }); setChatState('idle'); @@ -1383,6 +1399,7 @@ export function useTKMindChat( mindspaceContext?: MindSpaceChatContext; messageId?: string; forceDeepReasoning?: boolean; + pgRequired?: boolean; selectedChatSkill?: string; fileAttachments?: ChatFileAttachment[]; }, @@ -1412,7 +1429,10 @@ export function useTKMindChat( : ''; const userPrefix = buildUserAddressPrefix(userRef.current); const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []); - const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`; + const pgContractPrefix = options?.pgRequired + ? '[交付约束:用户已明确要求使用专属 PostgreSQL 数据空间。若生成页面,必须按 page-data-collect 完成建表、dataset、policy 和 workspace page 绑定;禁止 localStorage、SQLite、静态 JSON 或内存持久化。所有验证通过前不得回复已发布或给出页面链接。]\n' + : ''; + const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}${pgContractPrefix}`; const priorMessageCount = messagesRef.current.length; const userMessage = buildUserMessage(trimmed, { id: options?.messageId, @@ -1429,6 +1449,7 @@ export function useTKMindChat( ? userMessage.metadata.memindRun : {}), sessionMessageCount: priorMessageCount, + ...(options?.pgRequired ? { pgRequired: true } : {}), ...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}), }, }; @@ -1582,6 +1603,11 @@ export function useTKMindChat( const nextChatState = resolvePostAgentRunChatState({ chatState: chatStateRef.current, finishedViaPortalDirectChat, + // The agent-run result is authoritative even when the immediate + // session snapshot has not yet carried portal-direct metadata. + // Without this, a completed Page Data task can re-enter streaming + // and leave the Stop button attached to no active request. + agentRunSucceeded: finishedRun.status === 'succeeded', }); if (nextChatState === 'idle') { clearActiveRequestMissingTimer(); diff --git a/src/index.css b/src/index.css index 7df14ed..fffe923 100644 --- a/src/index.css +++ b/src/index.css @@ -717,20 +717,25 @@ body, justify-content: center; width: auto; min-width: 0; - height: 30px; - padding: 2px 4px; + height: 24px; + padding: 2px 3px; border-radius: 6px; font-size: 10px; flex-direction: row; - gap: 3px; + gap: 2px; font-weight: 600; line-height: 1; white-space: normal; } .app-shell-h5 .chat-deep-reasoning-toggle input { - width: 11px; - height: 11px; + width: 9px; + height: 9px; +} + +.app-shell-h5 .chat-deep-reasoning-toggle-icon { + width: 13px; + height: 13px; } .app-shell-h5 .chat-deep-reasoning-toggle-label { @@ -1843,7 +1848,7 @@ body, .page-save-preview-hint { margin: 0; color: #7a8680; - font-size: 11px; + font-size: 10px; line-height: 1.4; } @@ -3061,6 +3066,7 @@ body, } .chat-deep-reasoning-toggle { + position: relative; display: inline-flex; flex: 0 0 auto; align-items: center; @@ -3101,6 +3107,70 @@ body, line-height: 1.1; } +.chat-deep-reasoning-toggle-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.chat-control-onboarding-active { + position: relative; + z-index: 31; + animation: chat-control-onboarding-pulse 1s ease-in-out infinite; +} + +.chat-control-onboarding-tip { + position: absolute; + bottom: calc(100% + 7px); + left: 50%; + z-index: 2; + width: max-content; + max-width: min(165px, calc(100vw - 20px)); + padding: 4px 6px; + border: 1px solid rgba(218, 229, 255, 0.6); + border-radius: 9px; + background: linear-gradient(135deg, rgba(73, 117, 239, 0.98), rgba(152, 91, 230, 0.98)); + box-shadow: 0 6px 14px rgba(103, 89, 221, 0.28), 0 1px 3px rgba(28, 38, 100, 0.2); + color: #fff; + font-size: 8px; + font-weight: 600; + line-height: 1.35; + letter-spacing: 0.01em; + pointer-events: none; + transform: translateX(-50%); + animation: chat-control-onboarding-tip-in 220ms ease-out both; +} + +.chat-control-onboarding-tip::after { + position: absolute; + bottom: -4px; + left: 50%; + width: 6px; + height: 6px; + border-right: 1px solid rgba(218, 229, 255, 0.6); + border-bottom: 1px solid rgba(218, 229, 255, 0.6); + background: #835fdb; + content: ''; + transform: translateX(-50%) rotate(45deg); +} + +@keyframes chat-control-onboarding-pulse { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-2px) scale(1.07); } +} + +@keyframes chat-control-onboarding-tip-in { + from { opacity: 0; transform: translate(-50%, 4px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +@media (prefers-reduced-motion: reduce) { + .chat-control-onboarding-active, + .chat-control-onboarding-tip { + animation: none; + } +} + .chat-deep-reasoning-toggle.is-active { color: #eff6ff; border-color: rgba(121, 183, 255, 0.6); @@ -9938,12 +10008,12 @@ body, justify-content: center; width: auto; min-width: 0; - height: 30px; - padding: 2px 4px; + height: 24px; + padding: 2px 3px; border-radius: 6px; font-size: 10px; flex-direction: row; - gap: 3px; + gap: 2px; font-weight: 600; white-space: normal; } @@ -9953,8 +10023,13 @@ body, } .chat-deep-reasoning-toggle input { - width: 11px; - height: 11px; + width: 9px; + height: 9px; + } + + .chat-deep-reasoning-toggle-icon { + width: 13px; + height: 13px; } .chat-deep-reasoning-toggle-label {