From ae9090948a5f6f4777d2c4ba5802f9b7e5ad54d6 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 13 Jul 2026 15:39:39 +0800 Subject: [PATCH 1/2] fix(mindspace): relax published page CSP so agent HTML interactions work Switch default MindSpace HTML delivery to a hybrid inline CSP so onclick handlers and addEventListener pages both run under real HTTP headers, and add Playwright interaction verify beyond HTTP 200 checks. Co-authored-by: Cursor --- mindspace-published-page-csp.mjs | 45 ++-- mindspace-published-page-csp.test.mjs | 27 +- package.json | 1 + scripts/verify-public-page-interaction.mjs | 280 +++++++++++++++++++++ 4 files changed, 323 insertions(+), 30 deletions(-) create mode 100644 scripts/verify-public-page-interaction.mjs diff --git a/mindspace-published-page-csp.mjs b/mindspace-published-page-csp.mjs index 8918ac5..9022214 100644 --- a/mindspace-published-page-csp.mjs +++ b/mindspace-published-page-csp.mjs @@ -12,6 +12,28 @@ export function htmlUsesExternalScriptSrc(html) { return /]*\bsrc\s*=/i.test(String(html ?? '')); } +function buildHybridPublishedPageCsp(html, { extraScriptUrls = [] } = {}) { + const scriptSrc = scriptSrcDirective({ + inline: true, + urls: [ + ...(htmlUsesExternalScriptSrc(html) ? ["'self'"] : []), + ...extraScriptUrls, + ], + }); + return [ + "default-src 'none'", + "style-src 'unsafe-inline' https:", + "img-src 'self' data: http: https:", + "font-src 'self' https: data:", + "connect-src 'self'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'self'", + "script-src-attr 'unsafe-inline'", + scriptSrc, + ].join('; '); +} + export function publishedPageCsp( html, { embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {}, @@ -21,27 +43,10 @@ export function publishedPageCsp( return publishedPageCspForEmbed(true); } if (wechatShare && isFullHtml) { - const scriptSrc = scriptSrcDirective({ - inline: true, - urls: [ - ...(htmlUsesExternalScriptSrc(html) ? ["'self'"] : []), - 'https://res.wx.qq.com', - ], - }); - return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrc}`; + return buildHybridPublishedPageCsp(html, { extraScriptUrls: ['https://res.wx.qq.com'] }); } - if (raw && isFullHtml) { - const scriptSrc = scriptSrcDirective({ - inline: true, - urls: htmlUsesExternalScriptSrc(html) ? ["'self'"] : [], - }); - return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrc}`; - } - if (isFullHtml) { - return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ - hashes: scriptHashes, - urls: htmlUsesExternalScriptSrc(html) ? ["'self'"] : [], - })}`; + if ((raw || isFullHtml) && isFullHtml) { + return buildHybridPublishedPageCsp(html); } return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'"; } diff --git a/mindspace-published-page-csp.test.mjs b/mindspace-published-page-csp.test.mjs index 6962b3b..a0f5d35 100644 --- a/mindspace-published-page-csp.test.mjs +++ b/mindspace-published-page-csp.test.mjs @@ -7,26 +7,33 @@ const PAGE_DATA_HTML = ` `; -test('publishedPageCsp raw mode allows same-origin external scripts for page-data-client.js', () => { - const csp = publishedPageCsp(PAGE_DATA_HTML, { raw: true }); +const INLINE_ONCLICK_HTML = ` + +`; + +test('publishedPageCsp default full html uses hybrid inline policy with self scripts', () => { + const csp = publishedPageCsp(PAGE_DATA_HTML); assert.match(csp, /script-src 'unsafe-inline' 'self'/); + assert.match(csp, /script-src-attr 'unsafe-inline'/); + assert.doesNotMatch(csp, /'sha256-/); }); -test('publishedPageCsp raw mode keeps inline-only pages restricted', () => { - const html = ''; - const csp = publishedPageCsp(html, { raw: true }); +test('publishedPageCsp default full html allows dynamic onclick handlers', () => { + const csp = publishedPageCsp(INLINE_ONCLICK_HTML); assert.match(csp, /script-src 'unsafe-inline'/); - assert.doesNotMatch(csp, /script-src 'unsafe-inline' 'self'/); + assert.match(csp, /script-src-attr 'unsafe-inline'/); }); -test('publishedPageCsp non-raw full html allows self when external scripts are present', () => { - const csp = publishedPageCsp(PAGE_DATA_HTML, { raw: false }); - assert.match(csp, /script-src 'self'/); +test('publishedPageCsp raw mode matches default hybrid policy for full html', () => { + const rawCsp = publishedPageCsp(PAGE_DATA_HTML, { raw: true }); + const defaultCsp = publishedPageCsp(PAGE_DATA_HTML, { raw: false }); + assert.equal(rawCsp, defaultCsp); }); -test('publishedPageCsp wechatShare mode allows self when page-data-client.js is referenced', () => { +test('publishedPageCsp wechatShare mode allows self and wx bridge scripts', () => { const csp = publishedPageCsp(PAGE_DATA_HTML, { wechatShare: true }); assert.match(csp, /script-src 'unsafe-inline' 'self' https:\/\/res\.wx\.qq\.com/); + assert.match(csp, /script-src-attr 'unsafe-inline'/); }); test('publishedPageCsp wechatShare mode keeps inline-only pages without self', () => { diff --git a/package.json b/package.json index f8ed31c..9e5b039 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", "verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs", + "verify:public-page-interaction": "node scripts/verify-public-page-interaction.mjs", "verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs page-data-delivery-assess.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs", "verify:page-data-delivery": "node scripts/repair-page-data-workspace-bindings.mjs --dry-run", "repair:page-data-bindings": "node scripts/repair-page-data-workspace-bindings.mjs", diff --git a/scripts/verify-public-page-interaction.mjs b/scripts/verify-public-page-interaction.mjs new file mode 100644 index 0000000..1c0f9c4 --- /dev/null +++ b/scripts/verify-public-page-interaction.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node +/** + * Verify MindSpace published pages under real HTTP CSP — interactions, not just HTTP 200. + * + * Requires: local Portal (pnpm dev) + playwright. + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PUBLISH_ROOT_DIR } from '../user-publish.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); +const base = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:8081'; +const userId = process.env.VERIFY_USER_ID || '1e655eff-69ce-4f56-9d74-b02883e4112a'; + +const checks = []; + +function record(name, ok, details = {}) { + checks.push({ name, ok: Boolean(ok), ...details }); + const mark = ok ? 'PASS' : 'FAIL'; + console.log(`${mark} ${name}${details.detail ? ` — ${details.detail}` : ''}`); +} + +function pageUrl(relativePath) { + return `${base}/${PUBLISH_ROOT_DIR}/${userId}/${relativePath}`; +} + +const ONCLICK_FIXTURE = ` + + + + onclick fixture + + + +
idle
+ + +`; + +const ADD_EVENT_LISTENER_FIXTURE = ` + +listener fixture + + +
idle
+ + +`; + +async function writeFixture(relativePath, html) { + const diskPath = path.join(repoRoot, PUBLISH_ROOT_DIR, userId, relativePath); + await fs.mkdir(path.dirname(diskPath), { recursive: true }); + await fs.writeFile(diskPath, html, 'utf8'); +} + +async function launchBrowser() { + const { chromium } = await import('playwright'); + const browser = await chromium.launch({ headless: true }); + return browser; +} + +function attachDiagnostics(page, label) { + const cspViolations = []; + page.on('console', (message) => { + const text = message.text(); + if (/Content Security Policy|Refused to execute|blocked by CSP/i.test(text)) { + cspViolations.push(`${label}: ${text}`); + } + }); + page.on('pageerror', (error) => { + cspViolations.push(`${label}: pageerror ${error.message}`); + }); + return cspViolations; +} + +async function testFixturePage(browser, relativePath, { clickSelector, evaluateReady }) { + const page = await browser.newPage(); + const violations = attachDiagnostics(page, relativePath); + await page.goto(pageUrl(relativePath), { waitUntil: 'networkidle', timeout: 25_000 }); + await page.click(clickSelector); + await page.waitForTimeout(250); + const ready = await page.evaluate(evaluateReady); + const csp = (await page.evaluate(() => document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content)) + ?? ''; + const headerProbe = await fetch(pageUrl(relativePath)).then((res) => res.headers.get('content-security-policy') ?? ''); + await page.close(); + return { ready, violations, csp: headerProbe || csp }; +} + +async function testStickyNoteInteractions(browser) { + const page = await browser.newPage(); + const violations = attachDiagnostics(page, 'sticky-note-reminder.html'); + const url = pageUrl('public/sticky-note-reminder.html'); + await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 }); + + await page.click('#prioSelector .priority-option.high'); + const priorityActive = await page.evaluate(() => + document.querySelector('#prioSelector .priority-option.high')?.classList.contains('active'), + ); + + const uniqueTitle = `E2E-${Date.now()}`; + const beforeTotal = Number(await page.textContent('#statTotal')); + await page.fill('#inputTitle', uniqueTitle); + await page.fill('#inputContent', '自动化交互测试内容,用于验证动态 onclick 展开。'); + await page.click('#btnSubmit'); + await page.waitForFunction( + (title) => [...document.querySelectorAll('.card-title')].some((el) => el.textContent?.includes(title)), + uniqueTitle, + { timeout: 10_000 }, + ); + const afterTotal = Number(await page.textContent('#statTotal')); + + await page.click(`.card-title:text("${uniqueTitle}")`); + const cardContent = page.locator('.timeline-item', { hasText: uniqueTitle }).locator('.card-content').first(); + await cardContent.click(); + await page.waitForTimeout(300); + const expanded = await cardContent.evaluate((el) => el.classList.contains('expanded')); + + const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? ''); + await page.close(); + + return { + violations, + priorityActive, + totalIncreased: afterTotal > beforeTotal, + expanded, + headerCsp, + }; +} + +async function testPlantTreeInteractions(browser) { + const page = await browser.newPage(); + const violations = attachDiagnostics(page, 'plant-tree.html'); + const url = pageUrl('public/plant-tree.html'); + await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 }); + + const username = `E2E-${Date.now()}`; + await page.fill('#usernameInput', username); + await page.click('#plantBtn'); + await page.waitForFunction( + () => { + const toast = document.querySelector('.toast.show, .toast[style*="opacity: 1"]'); + return toast && /种下/.test(toast.textContent || ''); + }, + { timeout: 10_000 }, + ).catch(async () => { + await page.waitForTimeout(1500); + }); + + const toastText = await page.evaluate(() => { + const nodes = [...document.querySelectorAll('.toast, #toast')]; + return nodes.map((node) => node.textContent?.trim()).filter(Boolean).join(' | '); + }); + await page.waitForFunction( + (name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)), + username, + { timeout: 10_000 }, + ).catch(() => {}); + const listed = await page.evaluate( + (name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)), + username, + ); + + const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? ''); + await page.close(); + + return { + violations, + toastText, + listed, + headerCsp, + }; +} + +async function main() { + console.log(`\n=== MindSpace public page interaction verify @ ${base} ===\n`); + + const health = await fetch(`${base}/auth/status`).then((r) => r.ok).catch(() => false); + record('portal_running', health, { detail: health ? 'auth/status OK' : 'server down' }); + if (!health) { + process.exitCode = 1; + return; + } + + await writeFixture('public/csp-onclick-fixture.html', ONCLICK_FIXTURE); + await writeFixture('public/csp-listener-fixture.html', ADD_EVENT_LISTENER_FIXTURE); + + let browser; + try { + browser = await launchBrowser(); + } catch (error) { + record('playwright_available', false, { detail: error?.message || String(error) }); + process.exitCode = 1; + return; + } + record('playwright_available', true, { detail: 'chromium launched' }); + + const onclick = await testFixturePage(browser, 'public/csp-onclick-fixture.html', { + clickSelector: '#btn', + evaluateReady: () => ({ + bodyClicked: document.body.dataset.clicked === '1', + listenerStatus: document.getElementById('status')?.textContent ?? '', + }), + }); + record('fixture_onclick_attribute_executes', onclick.ready.bodyClicked, { + detail: JSON.stringify(onclick.ready), + }); + record('fixture_onclick_with_listener_both_work', onclick.ready.listenerStatus === 'listener-ok', { + detail: onclick.ready.listenerStatus, + }); + record('fixture_onclick_no_csp_violations', onclick.violations.length === 0, { + detail: onclick.violations.join(' | ') || 'none', + }); + record('fixture_onclick_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(onclick.csp), { + detail: onclick.csp.match(/script-src[^;]+/)?.[0] ?? onclick.csp.slice(0, 120), + }); + + const listener = await testFixturePage(browser, 'public/csp-listener-fixture.html', { + clickSelector: '#btn', + evaluateReady: () => ({ + bodyClicked: document.body.dataset.clicked === '1', + status: document.getElementById('status')?.textContent ?? '', + }), + }); + record('fixture_add_event_listener_executes', listener.ready.status === 'ok', { + detail: JSON.stringify(listener.ready), + }); + record('fixture_add_event_listener_no_csp_violations', listener.violations.length === 0, { + detail: listener.violations.join(' | ') || 'none', + }); + + const sticky = await testStickyNoteInteractions(browser); + record('sticky_note_priority_click_works', sticky.priorityActive, { detail: String(sticky.priorityActive) }); + record('sticky_note_submit_increases_total', sticky.totalIncreased, { detail: String(sticky.totalIncreased) }); + record('sticky_note_dynamic_onclick_expand_works', sticky.expanded, { + detail: sticky.expanded ? 'card expanded' : 'onclick expand blocked', + }); + record('sticky_note_no_csp_violations', sticky.violations.length === 0, { + detail: sticky.violations.join(' | ') || 'none', + }); + record('sticky_note_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(sticky.headerCsp), { + detail: sticky.headerCsp.match(/script-src[^;]+/)?.[0] ?? sticky.headerCsp.slice(0, 120), + }); + + const plant = await testPlantTreeInteractions(browser); + record('plant_tree_submit_shows_toast', /种下/.test(plant.toastText), { detail: plant.toastText || 'empty' }); + record('plant_tree_record_listed', plant.listed, { detail: String(plant.listed) }); + record('plant_tree_no_csp_violations', plant.violations.length === 0, { + detail: plant.violations.join(' | ') || 'none', + }); + + await browser.close(); + + const ok = checks.every((item) => item.ok); + console.log(`\n=== ${ok ? 'ALL PASS — published pages interact correctly under CSP' : 'SOME FAILED'} (${checks.filter((item) => item.ok).length}/${checks.length}) ===\n`); + if (!ok) { + console.log('Failed checks:'); + for (const item of checks.filter((entry) => !entry.ok)) { + console.log(` - ${item.name}${item.detail ? `: ${item.detail}` : ''}`); + } + console.log('\nTip: restart dev server (pnpm dev) after CSP policy changes.\n'); + } + process.exitCode = ok ? 0 : 1; +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 27b11894f2efb098ff6b626e83f8686399f69a12 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 14 Jul 2026 19:13:01 +0800 Subject: [PATCH 2/2] feat(mindspace): gate published page delivery --- chat-agent-run-gate.test.mjs | 11 +++ db.mjs | 22 +++++ mindspace-delivery-contract.mjs | 48 +++++++++++ mindspace-delivery-contract.test.mjs | 42 ++++++++++ mindspace-page-data-finish-guard.mjs | 22 +++-- mindspace-page-data-finish-guard.test.mjs | 21 +++++ mindspace-public-finish-sync.mjs | 13 +++ schema.sql | 19 +++++ server.mjs | 80 ++++++++++++++++++- src/api/client.ts | 4 + src/components/ChatPanel.tsx | 66 +++++++++++---- src/components/ChatSkillPicker.tsx | 7 +- src/components/ChatView.tsx | 1 + src/components/SpaceChatPanel.tsx | 2 + src/hooks/useTKMindChat.ts | 32 +++++++- src/index.css | 97 ++++++++++++++++++++--- 16 files changed, 448 insertions(+), 39 deletions(-) create mode 100644 mindspace-delivery-contract.mjs create mode 100644 mindspace-delivery-contract.test.mjs 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 {