merge: integrate hybrid published page delivery

This commit is contained in:
john
2026-07-14 19:13:17 +08:00
20 changed files with 771 additions and 69 deletions
+11
View File
@@ -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);
+22
View File
@@ -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(`
+48
View File
@@ -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;
}
+42
View File
@@ -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'")));
});
+17 -5
View File
@@ -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 或自建后端',
+21
View File
@@ -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 = '帮我做一个家庭记账页面,我每天可以记录日期、收入、支出、分类和备注,日期默认当天。再做一个管理页面,可以查看所有记录和收支汇总。';
+13
View File
@@ -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;
+25 -20
View File
@@ -12,6 +12,28 @@ export function htmlUsesExternalScriptSrc(html) {
return /<script\b[^>]*\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'";
}
+17 -10
View File
@@ -7,26 +7,33 @@ const PAGE_DATA_HTML = `<!doctype html><html><head></head><body>
<script>var client = MindSpacePageData.createClient({ apiBase: '/api' });</script>
</body></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 = `<!doctype html><html><body>
<button id="btn" onclick="document.body.dataset.clicked='1'">点我</button>
</body></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 = '<!doctype html><html><body><script>console.log(1)</script></body></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', () => {
+1
View File
@@ -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",
+19
View File
@@ -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,
+280
View File
@@ -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 = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>onclick fixture</title>
</head>
<body>
<button id="btn" onclick="document.body.dataset.clicked='1'">点我</button>
<div id="status">idle</div>
<script>
document.getElementById('btn').addEventListener('click', function () {
document.getElementById('status').textContent = 'listener-ok';
});
</script>
</body>
</html>`;
const ADD_EVENT_LISTENER_FIXTURE = `<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>listener fixture</title></head>
<body>
<button id="btn">点我</button>
<div id="status">idle</div>
<script>
document.getElementById('btn').addEventListener('click', function () {
document.body.dataset.clicked = '1';
document.getElementById('status').textContent = 'ok';
});
</script>
</body>
</html>`;
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;
});
+78 -2
View File
@@ -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 <base>.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)) {
+4
View File
@@ -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, '读取通知失败');
}
+49 -17
View File
@@ -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<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
const [pgRequired, setPgRequired] = useState(false);
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [pendingFiles, setPendingFiles] = useState<PendingChatFile[]>([]);
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({
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
onboardingActive={chatControlOnboardingStep === 0}
onSelect={submitText}
onPrefill={(prompt, skillId) => {
pendingSkillRef.current = skillId ?? null;
@@ -1196,23 +1213,38 @@ export function ChatPanel({
}}
/>
)}
{!showHomeWelcome && (
<label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成"
>
<input
type="checkbox"
checked={forceDeepReasoning}
disabled={voiceDisabled}
onChange={(event) => setForceDeepReasoning(event.target.checked)}
/>
<span className="chat-deep-reasoning-toggle-label" aria-hidden="true">
<span></span>
<span></span>
</span>
</label>
)}
<label
className={`chat-deep-reasoning-toggle${pgRequired ? ' is-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮生成的可保存数据必须使用你的专属 PostgreSQL 数据空间"
>
<input
type="checkbox"
checked={pgRequired}
disabled={voiceDisabled}
aria-label="使用 PostgreSQL 数据空间"
onChange={(event) => setPgRequired(event.target.checked)}
/>
<Database className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
{chatControlOnboardingStep === 1 && (
<span className="chat-control-onboarding-tip" role="status">使</span>
)}
</label>
<label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成"
>
<input
type="checkbox"
checked={forceDeepReasoning}
disabled={voiceDisabled}
aria-label="使用深度推理"
onChange={(event) => setForceDeepReasoning(event.target.checked)}
/>
<BrainCircuit className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
{chatControlOnboardingStep === 2 && (
<span className="chat-control-onboarding-tip" role="status"></span>
)}
</label>
{chatState === 'streaming' ? (
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
+6 -1
View File
@@ -5,11 +5,13 @@ import { ChatSkillIcon } from './ChatSkillIcons';
export function ChatSkillPicker({
skills,
disabled,
onboardingActive = false,
onSelect,
onPrefill,
}: {
skills: ChatSkillOption[];
disabled?: boolean;
onboardingActive?: boolean;
onSelect: (prompt: string, skillId?: string) => void | Promise<void>;
onPrefill?: (prompt: string, skillId?: string) => void;
}) {
@@ -50,7 +52,7 @@ export function ChatSkillPicker({
<div className={`chat-skill-picker${open ? ' open' : ''}`} ref={wrapRef}>
<button
type="button"
className="chat-skill-trigger"
className={`chat-skill-trigger${onboardingActive ? ' chat-control-onboarding-active' : ''}`}
disabled={disabled}
aria-expanded={open}
aria-haspopup="menu"
@@ -59,6 +61,9 @@ export function ChatSkillPicker({
>
<ChatSkillIcon id="spark" className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">skill</span>
{onboardingActive && (
<span className="chat-control-onboarding-tip" role="status"> Skill</span>
)}
</button>
{open && (
+1
View File
@@ -547,6 +547,7 @@ export function ChatView({
{
messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired,
selectedChatSkill: options?.selectedChatSkill,
fileAttachments: options?.fileAttachments,
},
+2
View File
@@ -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,
+29 -3
View File
@@ -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<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
@@ -329,6 +333,7 @@ export function useTKMindChat(
const subscribedSessionIdRef = useRef<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const connectTokenRef = useRef(0);
const unavailableAgentSessionIdsRef = useRef(new Set<string>());
const messagesRef = useRef<Message[]>([]);
const sessionRef = useRef<Session | null>(null);
const sessionsRef = useRef<SessionSummary[]>([]);
@@ -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();
+86 -11
View File
@@ -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 {