feat(mindspace): gate published page delivery
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'")));
|
||||
});
|
||||
@@ -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 或自建后端',
|
||||
|
||||
@@ -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 = '帮我做一个家庭记账页面,我每天可以记录日期、收入、支出、分类和备注,日期默认当天。再做一个管理页面,可以查看所有记录和收支汇总。';
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
@@ -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,
|
||||
|
||||
+78
-2
@@ -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)) {
|
||||
|
||||
@@ -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, '读取通知失败');
|
||||
}
|
||||
|
||||
@@ -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()}>
|
||||
停止
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -547,6 +547,7 @@ export function ChatView({
|
||||
{
|
||||
messageId: options?.messageId,
|
||||
forceDeepReasoning: options?.forceDeepReasoning,
|
||||
pgRequired: options?.pgRequired,
|
||||
selectedChatSkill: options?.selectedChatSkill,
|
||||
fileAttachments: options?.fileAttachments,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user