feat: release chat and mindspace UI updates

This commit is contained in:
john
2026-06-27 21:57:21 +08:00
parent c924b2bb77
commit 8264e71f9e
24 changed files with 2042 additions and 209 deletions
+7
View File
@@ -203,6 +203,13 @@ export async function migrateSchema(pool) {
}
}
if (!(await columnExists(pool, 'h5_session_snapshots', 'display_title'))) {
await pool.query(
`ALTER TABLE h5_session_snapshots
ADD COLUMN display_title VARCHAR(512) NOT NULL DEFAULT '' AFTER name`,
);
}
await pool.query(`UPDATE h5_users SET slug = username WHERE slug IS NULL OR slug = ''`);
if (!(await indexExists(pool, 'h5_users', 'uq_h5_users_slug'))) {
await pool.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_slug (slug)`);
+61
View File
@@ -537,6 +537,22 @@ export function createPageService(pool, options = {}) {
return rows[0] ? pageResponse(rows[0]) : null;
};
const findPageBySourceMessage = async (userId, sessionId, messageId) => {
if (!sessionId || !messageId) return null;
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
FROM h5_page_records p
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
WHERE p.user_id = ? AND p.source_session_id = ? AND p.source_message_id = ? AND p.status <> 'deleted'
ORDER BY p.updated_at DESC
LIMIT 1`,
[userId, sessionId, messageId],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
const listPages = async (userId, filters = {}) => {
const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`];
const params = [userId];
@@ -1148,6 +1164,7 @@ export function createPageService(pool, options = {}) {
createRedactedCopy: redactPage,
listPages,
findPageBySourceAsset,
findPageBySourceMessage,
getPage,
getDeletePreview,
deletePage,
@@ -1211,6 +1228,50 @@ export const pageInternals = {
buildPageDeleteSummary,
};
export async function inlinePrivateAssetsInHtml(pool, storageRoot, userId, html) {
const PATTERN =
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
const resolvedRoot = path.resolve(storageRoot);
const absoluteStoragePath = (key) => {
const resolved = path.resolve(resolvedRoot, key);
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
throw Object.assign(new Error('路径越界'), { code: 'invalid_storage_path' });
}
return resolved;
};
const matches = [...String(html).matchAll(PATTERN)];
if (matches.length === 0) return { html, count: 0 };
const assetIds = [...new Set(matches.map((m) => m[1]).filter(Boolean))];
const [assets] = await pool.query(
`SELECT a.id, a.mime_type, a.original_filename, v.storage_key
FROM h5_assets a
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%'
AND a.status <> 'deleted'`,
[userId, assetIds],
);
const dataUriMap = new Map();
for (const asset of assets) {
try {
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
dataUriMap.set(asset.id, `data:${asset.mime_type};base64,${buffer.toString('base64')}`);
} catch {
// skip unreadable assets
}
}
let count = 0;
const result = String(html).replace(PATTERN, (_match, assetId) => {
const uri = dataUriMap.get(assetId);
if (!uri) return '';
count += 1;
return uri;
});
return { html: result, count };
}
export function buildPageDeleteSummary(preview) {
const lines = [
`页面「${preview.page.title}」及其 ${preview.page.versionCount} 个版本`,
+54 -2
View File
@@ -12,8 +12,10 @@
"@resvg/resvg-js": "^2.6.2",
"debug": "^4.4.3",
"express": "^4.21.2",
"framer-motion": "^12.42.0",
"http-proxy-middleware": "^3.0.3",
"jsonrepair": "^3.14.0",
"lucide-react": "^1.21.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
"qrcode": "^1.5.4",
@@ -2558,6 +2560,33 @@
"node": ">= 0.6"
}
},
"node_modules/framer-motion": {
"version": "12.42.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.0.tgz",
"integrity": "sha512-wp7EJnfWaaEScVygKv3e20udoRz+LbtxScsuTkakAxfXmt+ReC6WyPW2nINRAGvd+hG9odwcjBLyOTPjH5pBRA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.42.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -2937,6 +2966,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/lucide-react": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
"integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3019,6 +3057,21 @@
"node": ">= 0.6"
}
},
"node_modules/motion-dom": {
"version": "12.42.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.0.tgz",
"integrity": "sha512-M63h4n8R+quJdNhBwuLlgxM+OLYa9+I/T2pzDRboB9fLXRdbou+Gw7Zury+SkpaCyACP1JHSjHgZ1EgTkBr30w==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3944,8 +3997,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
+2
View File
@@ -54,8 +54,10 @@
"@resvg/resvg-js": "^2.6.2",
"debug": "^4.4.3",
"express": "^4.21.2",
"framer-motion": "^12.42.0",
"http-proxy-middleware": "^3.0.3",
"jsonrepair": "^3.14.0",
"lucide-react": "^1.21.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
"qrcode": "^1.5.4",
+1
View File
@@ -660,6 +660,7 @@ CREATE TABLE IF NOT EXISTS h5_session_snapshots (
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
name VARCHAR(512) NOT NULL DEFAULT '',
display_title VARCHAR(512) NOT NULL DEFAULT '',
working_dir VARCHAR(1024) NOT NULL DEFAULT '',
created_at_str VARCHAR(64) NOT NULL DEFAULT '',
updated_at_str VARCHAR(64) NOT NULL DEFAULT '',
+63 -7
View File
@@ -1,6 +1,7 @@
import express from 'express';
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -23,7 +24,7 @@ import {
} from './user-auth.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir, resolvePublicBaseUrl } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
@@ -32,7 +33,7 @@ import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
import { createAssetService } from './mindspace-assets.mjs';
import { createPageService, pageInternals } from './mindspace-pages.mjs';
import { createPageService, pageInternals, inlinePrivateAssetsInHtml } from './mindspace-pages.mjs';
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
@@ -520,6 +521,7 @@ async function bootstrapUserAuth() {
userAuth,
llmProviderService,
subscriptionService,
sessionSnapshotService,
localFetchAsset: mindSpaceAssets
? async (userId, assetId) => {
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId);
@@ -2506,6 +2508,13 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
thumbnailReady = false;
}
}
const sessionId = String(req.body?.session_id ?? req.body?.sessionId ?? '').trim();
const messageId = String(req.body?.message_id ?? req.body?.messageId ?? '').trim();
const existingPage = await mindSpacePages.findPageBySourceMessage(
req.currentUser.id,
sessionId,
messageId,
).catch(() => null);
return sendData(res, req, {
contentMode: analysis.contentMode,
links: analysis.links,
@@ -2524,12 +2533,51 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
filename: resolvedHtml?.filename ?? analysis.filename,
hasHtmlContent: Boolean(resolvedHtml),
privacyScan: scanContent(resolvedHtml?.content ?? source.content),
existingPage: existingPage
? { id: existingPage.id, title: existingPage.title, categoryCode: existingPage.categoryCode }
: null,
});
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const storageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
const { html: localizedHtml } = await inlinePrivateAssetsInHtml(
authPool,
storageRoot,
req.currentUser.id,
bundle.resolvedHtml.content,
);
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared');
await fsPromises.mkdir(sharedDir, { recursive: true });
const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, '');
const filename = `${basename}-${crypto.randomUUID().slice(0, 8)}.html`;
const destPath = path.join(sharedDir, filename);
await fsPromises.writeFile(destPath, localizedHtml, 'utf8');
const publishKey = req.currentUser.id;
const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${PUBLIC_ZONE_DIR}/shared/${encodeURIComponent(filename)}`;
return res.status(201).json({ data: { publicUrl, filename } });
} catch (error) {
console.error('[quick-share] error:', error);
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
if (!mindSpacePages || !mindSpaceAssets) {
return res.status(503).json({ message: 'MindSpace 未启用' });
@@ -2635,11 +2683,19 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
}).catch(() => {});
}
const page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
sessionId: req.body.session_id,
messageId: req.body.message_id,
snapshot,
});
const replacePageId = req.body?.replace_page_id
? String(req.body.replace_page_id).trim()
: null;
let page;
if (replacePageId) {
page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, pageInput);
} else {
page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
sessionId: req.body.session_id,
messageId: req.body.message_id,
snapshot,
});
}
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
} catch (error) {
return mindSpaceError(res, req, error);
+184 -6
View File
@@ -11,11 +11,78 @@
* conversation content, so it can be dropped/truncated safely at any time.
*/
const DERIVED_TITLE_MAX_LEN = 40;
function isGeneratedSessionName(name) {
return /^20\d{6}(?:[_-]\d+)?$/.test(String(name ?? '').trim());
}
function isDefaultSessionName(name) {
const trimmed = String(name ?? '').trim();
return !trimmed || trimmed === 'New Chat' || trimmed === '新对话' || isGeneratedSessionName(trimmed);
}
/** Extract a plain-text snippet from a stored normalized message. */
function messageSnippet(message) {
const fromContent = (message?.content ?? [])
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('')
.trim();
if (fromContent) return fromContent;
const displayText = message?.metadata?.displayText;
return typeof displayText === 'string' ? displayText.trim() : '';
}
/** Build a one-line title from the first user message of a stored conversation. */
function deriveTitleFromMessages(messages) {
if (!Array.isArray(messages)) return '';
const firstUser = messages.find((m) => m?.role === 'user' && messageSnippet(m));
const text = (firstUser ? messageSnippet(firstUser) : '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return '';
return text.length > DERIVED_TITLE_MAX_LEN
? `${text.slice(0, DERIVED_TITLE_MAX_LEN)}`
: text;
}
function resolveDisplayTitle(session, messages) {
if (session?.user_set_name) {
const explicit = String(session.name ?? '').trim();
if (explicit) return explicit;
}
const recipeTitle = String(session?.recipe?.title ?? '').trim();
if (recipeTitle) return recipeTitle;
const sessionName = String(session?.name ?? '').trim();
if (sessionName && !isDefaultSessionName(sessionName)) return sessionName;
return deriveTitleFromMessages(messages);
}
export function createSessionSnapshotService(pool) {
function isEnabled() {
return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== '0';
}
async function persistDisplayTitle(sessionId, title) {
const normalized = String(title ?? '').trim();
if (!normalized || !pool) return;
try {
await pool.query(
`UPDATE h5_session_snapshots
SET display_title = ?
WHERE agent_session_id = ?
AND (display_title IS NULL OR display_title = '')`,
[normalized, sessionId],
);
} catch (err) {
console.warn('[snapshot] persistDisplayTitle failed:', err instanceof Error ? err.message : err);
}
}
/**
* Persist a snapshot for a session.
* @param {string} sessionId
@@ -29,6 +96,7 @@ export function createSessionSnapshotService(pool) {
const now = Date.now();
const sessionMeta = {
name: session.name ?? '',
display_title: resolveDisplayTitle(session, messages),
working_dir: session.working_dir ?? '',
created_at_str: session.created_at ?? '',
updated_at_str: session.updated_at ?? '',
@@ -37,13 +105,14 @@ export function createSessionSnapshotService(pool) {
};
await pool.query(
`INSERT INTO h5_session_snapshots
(agent_session_id, user_id, name, working_dir,
(agent_session_id, user_id, name, display_title, working_dir,
created_at_str, updated_at_str, user_set_name, recipe_json,
synced_msg_count, source_updated_at,
messages_json, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
display_title = VALUES(display_title),
working_dir = VALUES(working_dir),
created_at_str = VALUES(created_at_str),
updated_at_str = VALUES(updated_at_str),
@@ -57,6 +126,7 @@ export function createSessionSnapshotService(pool) {
sessionId,
userId,
sessionMeta.name,
sessionMeta.display_title,
sessionMeta.working_dir,
sessionMeta.created_at_str,
sessionMeta.updated_at_str,
@@ -82,7 +152,7 @@ export function createSessionSnapshotService(pool) {
if (!isEnabled() || !pool) return null;
try {
const [rows] = await pool.query(
`SELECT agent_session_id, user_id, name, working_dir,
`SELECT agent_session_id, user_id, name, display_title, working_dir,
created_at_str, updated_at_str, user_set_name, recipe_json,
synced_msg_count, source_updated_at, messages_json, synced_at
FROM h5_session_snapshots
@@ -92,10 +162,26 @@ export function createSessionSnapshotService(pool) {
);
if (!rows.length) return null;
const row = rows[0];
const parsedMessages = JSON.parse(row.messages_json);
const storedDisplayTitle = String(row.display_title ?? '').trim();
const rawName = String(row.name ?? '').trim();
const fallbackDisplayTitle =
storedDisplayTitle ||
resolveDisplayTitle(
{
name: row.name,
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
user_set_name: row.user_set_name === 1,
},
parsedMessages,
);
if (!storedDisplayTitle && fallbackDisplayTitle) {
void persistDisplayTitle(sessionId, fallbackDisplayTitle);
}
return {
session: {
id: sessionId,
name: row.name,
name: fallbackDisplayTitle || rawName,
working_dir: row.working_dir,
message_count: row.synced_msg_count,
created_at: row.created_at_str || undefined,
@@ -104,7 +190,7 @@ export function createSessionSnapshotService(pool) {
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
conversation: null,
},
messages: JSON.parse(row.messages_json),
messages: parsedMessages,
meta: {
synced_msg_count: row.synced_msg_count,
source_updated_at: row.source_updated_at,
@@ -153,5 +239,97 @@ export function createSessionSnapshotService(pool) {
}
}
return { save, get, remove, refresh, isEnabled };
/**
* Derive display titles from the first user message for the given session ids.
* Used to give unnamed/default sessions a meaningful label in the history list.
* Returns a Map<sessionId, title>; sessions without a usable snapshot are omitted.
*/
async function getDerivedTitles(sessionIds) {
if (!isEnabled() || !pool) return new Map();
const ids = [...new Set((sessionIds ?? []).filter(Boolean))];
if (ids.length === 0) return new Map();
const titles = new Map();
try {
const placeholders = ids.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT agent_session_id, display_title, name, recipe_json, user_set_name, messages_json
FROM h5_session_snapshots
WHERE agent_session_id IN (${placeholders})`,
ids,
);
for (const row of rows) {
try {
const parsedMessages = JSON.parse(row.messages_json);
const title =
String(row.display_title ?? '').trim() ||
resolveDisplayTitle(
{
name: row.name,
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
user_set_name: row.user_set_name === 1,
},
parsedMessages,
);
if (title) {
titles.set(row.agent_session_id, title);
if (!String(row.display_title ?? '').trim()) {
void persistDisplayTitle(row.agent_session_id, title);
}
}
} catch {
// Skip rows with unparseable message payloads.
}
}
} catch (err) {
console.warn('[snapshot] getDerivedTitles failed:', err instanceof Error ? err.message : err);
}
return titles;
}
async function getHistorySummaries(sessionIds) {
if (!isEnabled() || !pool) return new Map();
const ids = [...new Set((sessionIds ?? []).filter(Boolean))];
if (ids.length === 0) return new Map();
const summaries = new Map();
try {
const placeholders = ids.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT agent_session_id, display_title, name, recipe_json, user_set_name,
synced_msg_count, messages_json
FROM h5_session_snapshots
WHERE agent_session_id IN (${placeholders})`,
ids,
);
for (const row of rows) {
try {
const parsedMessages = JSON.parse(row.messages_json);
const title =
String(row.display_title ?? '').trim() ||
resolveDisplayTitle(
{
name: row.name,
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
user_set_name: row.user_set_name === 1,
},
parsedMessages,
);
const messageCount = Number(row.synced_msg_count ?? 0);
summaries.set(row.agent_session_id, {
title,
messageCount,
});
if (title && !String(row.display_title ?? '').trim()) {
void persistDisplayTitle(row.agent_session_id, title);
}
} catch {
// Skip rows with unparseable payloads.
}
}
} catch (err) {
console.warn('[snapshot] getHistorySummaries failed:', err instanceof Error ? err.message : err);
}
return summaries;
}
return { save, get, remove, refresh, getDerivedTitles, getHistorySummaries, isEnabled };
}
+21
View File
@@ -726,6 +726,25 @@ export function buildChatSaveThumbnailUrl(input: {
return `/api/mindspace/v1/pages/chat-save-thumbnail?${params.toString()}`;
}
export async function quickShareFromChat(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}): Promise<{ publicUrl: string; filename: string }> {
const result = await apiFetch<{ data: { publicUrl: string; filename: string } }>(
'/mindspace/v1/pages/quick-share-from-chat',
{
method: 'POST',
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
},
);
return result.data;
}
export async function fetchPreviewAsset(path: string): Promise<string> {
let res: Response;
try {
@@ -754,6 +773,7 @@ export async function saveChatMessageAsPage(input: {
categoryCode?: MindSpaceSaveCategory;
selectedLinkIndex?: number;
acknowledgedFindingIds?: string[];
replacePageId?: string;
}): Promise<ChatSaveResult> {
const result = await apiFetch<{ data: ChatSaveResult }>(
'/mindspace/v1/pages/save-from-chat',
@@ -769,6 +789,7 @@ export async function saveChatMessageAsPage(input: {
selected_link_index: input.selectedLinkIndex ?? 0,
acknowledged_finding_ids: input.acknowledgedFindingIds,
page_type: input.templateId === 'report' ? 'report' : 'article',
replace_page_id: input.replacePageId,
}),
},
);
+56 -17
View File
@@ -12,6 +12,38 @@ import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfir
import type { MindSpaceSaveCategory } from '../types';
const MAX_PENDING_IMAGES = 6;
const CHAT_PLACEHOLDER_PROMPTS = [
'帮我写一篇温柔一点的小短文',
'讲个轻松的笑话,让我换换脑子',
'帮我看看今天的天气和出门建议',
'规划一份周末城市漫游攻略',
'把这组数据整理成一段分析结论',
'帮我总结一份会议纪要或文档',
'写一个有记忆点的产品介绍',
'设计一个简单好玩的小游戏',
'帮我做一份旅行行程和预算',
'把一段想法改成更专业的表达',
'给我起几个品牌名和一句 slogan',
'帮我写一封礼貌但坚定的邮件',
'整理一份学习计划和每日任务',
'分析一个产品页面哪里可以优化',
'把复杂概念讲得像聊天一样简单',
'帮我生成一份短视频脚本',
'写一段适合发朋友圈的文案',
'帮我拆解一个商业想法是否可行',
'把长文压缩成三条重点',
'做一个活动策划和执行清单',
'帮我准备一次面试的回答思路',
'写一个网页或小工具的需求说明',
'帮我把表格数据变成洞察报告',
'给孩子讲一个睡前故事',
'帮我规划一周健康饮食',
'把这段话翻译得自然一点',
'帮我做一份竞品对比',
'给一个新功能设计使用流程',
'帮我写一段代码并解释思路',
'整理一个今天就能开始的行动计划',
];
type PendingChatImage = {
id: string;
@@ -76,6 +108,9 @@ export function ChatPanel({
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
const imageInputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef(input);
@@ -105,6 +140,7 @@ export function ChatPanel({
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const showHomeWelcome = !compact && messages.length === 0;
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
@@ -112,7 +148,7 @@ export function ChatPanel({
? '正在连接会话…'
: compact
? '在这里继续和 Agent 对话…'
: '输入任务,例如:列出当前目录文件';
: randomPrompt;
const mergeVoiceText = (spoken: string) => {
const base = voiceBaseRef.current.trimEnd();
@@ -321,7 +357,7 @@ export function ChatPanel({
return (
<>
<main className={compact ? 'space-chat-panel-body' : 'main'}>
<main className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}>
<MessageList
messages={messages}
streaming={chatState === 'streaming'}
@@ -329,6 +365,7 @@ export function ChatPanel({
onSaveAsPage={(message) => setPageSource(message)}
publishUserId={user?.id}
publishUsername={user?.username}
sessionId={session?.id}
compact={compact}
/>
{!compact && (
@@ -369,7 +406,7 @@ export function ChatPanel({
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : 'footer'}>
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{voiceNotice && (
<div className="voice-notice" role="status">
{voiceNotice}
@@ -399,8 +436,8 @@ export function ChatPanel({
)}
</div>
)}
<div className="chat-input-row">
<div className="chat-input-shell">
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
{onUploadImage && (
<>
<button
@@ -449,7 +486,7 @@ export function ChatPanel({
stopSignal={voiceStopSignal}
/>
</div>
{chatSkills.length > 0 && (
{!showHomeWelcome && chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
@@ -457,16 +494,18 @@ export function ChatPanel({
onPrefill={setInput}
/>
)}
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
{!showHomeWelcome && (
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
)}
{designPanelOpen && (
<DesignSkillPanel
onClose={() => setDesignPanelOpen(false)}
@@ -483,7 +522,7 @@ export function ChatPanel({
) : (
<button
type="button"
className="send-btn chat-input-action"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || voiceDisabled || uploadingImage}
onClick={() => void handleSubmit()}
>
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { buildChatSavePreviewFrameUrl, quickShareFromChat } from '../api/client';
type SharePhase =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'done'; publicUrl: string }
| { kind: 'error'; message: string };
export function ChatSharePreviewModal({
sessionId,
messageId,
selectedLinkIndex = 0,
onClose,
onSave,
}: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
onClose: () => void;
onSave?: () => void;
}) {
const [phase, setPhase] = useState<SharePhase>({ kind: 'idle' });
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewUrl = buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex });
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current); }, []);
const share = async () => {
if (phase.kind === 'loading') return;
setPhase({ kind: 'loading' });
try {
const result = await quickShareFromChat({ sessionId, messageId, selectedLinkIndex });
setPhase({ kind: 'done', publicUrl: result.publicUrl });
} catch (err) {
setPhase({ kind: 'error', message: err instanceof Error ? err.message : '公开分享失败,请重试' });
}
};
const copy = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
};
const handleSave = () => {
onClose();
onSave?.();
};
return createPortal(
<div className="chat-share-preview-overlay" role="dialog" aria-modal="true" aria-label="页面预览">
<div className="chat-share-preview-header">
<span className="chat-share-preview-title"></span>
<button type="button" className="chat-share-preview-close" onClick={onClose} aria-label="关闭"></button>
</div>
<div className="chat-share-preview-body">
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups" className="chat-share-preview-frame" />
<div className="chat-share-side-actions">
{phase.kind === 'done' ? (
<div className="chat-share-result-card">
<p className="chat-share-result-label"> </p>
<button type="button" className="chat-share-result-copy" onClick={() => void copy(phase.publicUrl)}>
{copied ? '已复制 ✓' : '复制链接'}
</button>
<a href={phase.publicUrl} target="_blank" rel="noreferrer" className="chat-share-result-open">
</a>
</div>
) : (
<button
type="button"
className={`chat-share-side-btn chat-share-side-btn-primary${phase.kind === 'loading' ? ' is-loading' : ''}`}
disabled={phase.kind === 'loading'}
onClick={() => void share()}
title="将页面转为公开链接,私有图片自动转为公开地址"
>
{phase.kind === 'loading' ? '处理中…' : '🌐 公开分享'}
</button>
)}
{phase.kind === 'error' && <p className="chat-share-side-error">{phase.message}</p>}
{onSave && (
<button type="button" className="chat-share-side-btn" onClick={handleSave}>
📄
</button>
)}
</div>
</div>
</div>,
document.body,
);
}
+4 -3
View File
@@ -72,6 +72,7 @@ export function ChatView({
}, [sidebarOpen, onSidebarOpen]);
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const showHomeWelcome = messages.length === 0;
const handleSelectSession = (sessionId: string) => {
void switchSession(sessionId);
@@ -85,7 +86,7 @@ export function ChatView({
const sessionTitle = session
? getSessionDisplayName(session)
: chatState === 'connecting'
: chatState === 'idle'
? '新对话'
: '连接中…';
@@ -128,8 +129,8 @@ export function ChatView({
onDelete={(sessionId) => void deleteSession(sessionId)}
/>
<div className="app">
<header className="header">
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
<div className="header-left">
<button
type="button"
+110
View File
@@ -0,0 +1,110 @@
import { motion } from 'framer-motion';
import {
PenSquare,
Code2,
BarChart3,
FileText,
Bot,
} from 'lucide-react';
import { TKMindAvatar } from './TKMindAvatar';
const features = [
{ icon: PenSquare, text: '写作创作' },
{ icon: Code2, text: '编程开发' },
{ icon: BarChart3, text: '数据分析' },
{ icon: FileText, text: '文档总结' },
{ icon: Bot, text: '自动执行' },
];
export function ChatWelcomePanel({ compact }: { compact?: boolean }) {
if (compact) {
return (
<div className="empty-state empty-state-compact">
<TKMindAvatar />
<h2>TKMind</h2>
<p> Agent </p>
</div>
);
}
return (
<div className="welcome-panel">
<div className="welcome-panel-content">
<motion.div
initial={{ opacity: 0, y: -15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="welcome-panel-avatar-wrap"
>
<TKMindAvatar size="lg" className="welcome-panel-avatar" />
</motion.div>
<motion.h2
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
className="welcome-panel-brand"
>
TKMind智趣
</motion.h2>
<motion.h1
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="welcome-panel-headline"
>
<span className="welcome-panel-headline-base"></span>
<span className="welcome-panel-headline-gradient">AI </span>
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="welcome-panel-subtitle"
>
</motion.p>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.55 }}
className="welcome-panel-description"
>
TKMind
</motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
className="welcome-panel-actions"
>
{features.map((item) => {
const Icon = item.icon;
return (
<div key={item.text} className="welcome-panel-pill">
<span className="welcome-panel-pill-icon" aria-hidden="true">
<Icon size={22} strokeWidth={2} />
</span>
<span>{item.text}</span>
</div>
);
})}
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.95 }}
className="welcome-panel-hint"
>
<div className="welcome-panel-hint-arrow"></div>
<div className="text-base"></div>
</motion.div>
</div>
</div>
);
}
+43 -13
View File
@@ -58,10 +58,12 @@ export function HistorySidebar({
}: HistorySidebarProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
useEffect(() => {
if (open) {
setVisibleCount(appConfig.sessionPageSize);
setPendingDeleteId(null);
}
}, [open, sessions.length]);
@@ -74,14 +76,22 @@ export function HistorySidebar({
}
}, [visibleCount, sessions.length]);
const handleDelete = useCallback(
(sessionId: string, label: string) => {
if (!window.confirm(`确定删除「${label}」?此操作不可恢复。`)) return;
const handleDeleteClick = useCallback((sessionId: string) => {
setPendingDeleteId(sessionId);
}, []);
const handleConfirmDelete = useCallback(
(sessionId: string) => {
setPendingDeleteId(null);
onDelete(sessionId);
},
[onDelete],
);
const handleCancelDelete = useCallback(() => {
setPendingDeleteId(null);
}, []);
if (!open) return null;
const visibleSessions = sessions.slice(0, visibleCount);
@@ -119,26 +129,46 @@ export function HistorySidebar({
<ul className="sidebar-list">
{group.sessions.map((item) => {
const label = getSessionListLabel(item);
const isPending = pendingDeleteId === item.id;
return (
<li key={item.id} className="sidebar-item-wrap">
<li key={item.id} className={`sidebar-item-wrap${isPending ? ' sidebar-item-confirming' : ''}`}>
<button
type="button"
className={`sidebar-item ${item.id === activeSessionId ? 'sidebar-item-active' : ''}`}
onClick={() => onSelect(item.id)}
onClick={() => { if (!isPending) onSelect(item.id); }}
>
<span className="sidebar-item-title">{label}</span>
<span className="sidebar-item-meta">
{formatSessionTime(item.updated_at ?? item.created_at)}
</span>
</button>
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDelete(item.id, label)}
>
<CloseIcon />
</button>
{isPending ? (
<div className="sidebar-item-confirm">
<button
type="button"
className="sidebar-confirm-yes"
onClick={() => handleConfirmDelete(item.id)}
>
</button>
<button
type="button"
className="sidebar-confirm-no"
onClick={handleCancelDelete}
>
</button>
</div>
) : (
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDeleteClick(item.id)}
>
<CloseIcon />
</button>
)}
</li>
);
})}
+74 -26
View File
@@ -8,6 +8,8 @@ import { filterText } from '../utils/wordFilter';
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
import { TKMindAvatar } from './TKMindAvatar';
import { UserAvatar } from './UserAvatar';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatWelcomePanel } from './ChatWelcomePanel';
function CopyIcon() {
return (
@@ -37,18 +39,58 @@ function CheckIcon() {
);
}
function ToolSpinnerIcon() {
return (
<span className="tool-badge-spinner" aria-hidden="true">
<span />
</span>
);
}
function ToolDoneIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ToolBadge({ message }: { message: Message }) {
const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
if (tools.length === 0) return null;
const completed = tools.filter((t) => t.type === 'toolResponse').length;
const total = tools.length;
return (
<div className="tool-badges">
{tools.map((tool, i) => (
<span key={`${message.id}-${i}`} className="tool-badge">
{tool.type === 'toolRequest'
? `🔧 ${tool.toolCall.functionName}`
: `✓ 工具完成`}
</span>
))}
{tools.map((tool, i) => {
const isRequest = tool.type === 'toolRequest';
const toolName = isRequest ? String(tool.toolCall.functionName ?? '').trim() : '';
let label: string;
if (isRequest && !toolName) {
label = `正在加载 tools (${completed}/${total})`;
} else {
label = isRequest ? toolName : '工具完成';
}
return (
<span
key={`${message.id}-${i}`}
className={`tool-badge${isRequest ? ' is-pending' : ' is-complete'}`}
>
{isRequest ? <ToolSpinnerIcon /> : <ToolDoneIcon />}
<span>{label}</span>
</span>
);
})}
</div>
);
}
@@ -126,6 +168,7 @@ function MessageRow({
saveDisabled,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
message: Message;
@@ -135,9 +178,11 @@ function MessageRow({
saveDisabled?: boolean;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const rawText = getDisplayText(message);
const text = filterText(rawText);
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
@@ -227,15 +272,14 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
onClick={() => setPreviewOpen(true)}
>
</a>
</button>
)}
</>
)}
@@ -254,15 +298,14 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
onClick={() => setPreviewOpen(true)}
>
</a>
</button>
)}
</>
)}
@@ -278,6 +321,14 @@ function MessageRow({
title={onAvatarClick ? '点击更换头像' : undefined}
/>
)}
{previewOpen && sessionId && message.id && (
<ChatSharePreviewModal
sessionId={sessionId}
messageId={message.id}
onClose={() => setPreviewOpen(false)}
onSave={onSaveAsPage ? () => onSaveAsPage(message) : undefined}
/>
)}
</div>
);
}
@@ -289,6 +340,7 @@ export function MessageList({
onSaveAsPage,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
messages: Message[];
@@ -297,19 +349,14 @@ export function MessageList({
onSaveAsPage?: (message: Message) => void;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const { avatarUrl } = useUserAvatar();
return (
<div className="message-list">
{messages.length === 0 && (
<div className={`empty-state${compact ? ' empty-state-compact' : ''}`}>
<TKMindAvatar />
<h2>TKMind</h2>
<p>{compact ? '继续和空间里的 Agent 对话' : '发送消息即可在本机执行 Agent 任务'}</p>
</div>
)}
{messages.length === 0 && <ChatWelcomePanel compact={compact} />}
{messages.map((message, index) => {
const prev = index > 0 ? messages[index - 1] : undefined;
const showTime = shouldShowTimestamp(message.created, prev?.created);
@@ -324,6 +371,7 @@ export function MessageList({
saveDisabled={streaming}
publishUserId={publishUserId}
publishUsername={publishUsername}
sessionId={sessionId}
compact={compact}
/>
</div>
+17
View File
@@ -54,11 +54,15 @@ export function MindSpaceFeedCard({
previewMode = false,
onClick,
actions,
checked,
onCheckChange,
}: {
page: MindSpacePage;
previewMode?: boolean;
onClick: () => void;
actions?: { label: string; onClick: (e: React.MouseEvent) => void }[];
checked?: boolean;
onCheckChange?: (checked: boolean) => void;
}) {
const typeLabel = feedTypeLabel(page);
const isHtml = page.contentFormat === 'html';
@@ -68,6 +72,19 @@ export function MindSpaceFeedCard({
<div className="mindspace-feed-card-wrap">
<button type="button" className="mindspace-feed-card" onClick={onClick}>
<div className="mindspace-feed-cover">
{onCheckChange !== undefined && (
<label
className="mindspace-feed-select"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={checked ?? false}
onChange={(e) => onCheckChange(e.target.checked)}
/>
<span>{checked ? '已选' : '选择'}</span>
</label>
)}
{isHtml ? (
<WorkCover
coverMode="thumbnail-first"
+93 -16
View File
@@ -476,6 +476,8 @@ export function MindSpaceView({
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [bulkDeletingAssets, setBulkDeletingAssets] = useState(false);
const [selectedDraftPageIds, setSelectedDraftPageIds] = useState<string[]>([]);
const [bulkDeletingDraftPages, setBulkDeletingDraftPages] = useState(false);
const [newPageOpen, setNewPageOpen] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const [newPageContent, setNewPageContent] = useState('');
@@ -1335,6 +1337,55 @@ export function MindSpaceView({
}
};
const selectedDraftPageSet = useMemo(() => new Set(selectedDraftPageIds), [selectedDraftPageIds]);
const allDraftPagesSelected =
pages.length > 0 && pages.every((p) => selectedDraftPageSet.has(p.id));
const toggleDraftPageSelected = (pageId: string) => {
setSelectedDraftPageIds((ids) =>
ids.includes(pageId) ? ids.filter((id) => id !== pageId) : [...ids, pageId],
);
};
const toggleAllDraftPagesSelected = () => {
if (allDraftPagesSelected) {
setSelectedDraftPageIds([]);
} else {
setSelectedDraftPageIds(pages.map((p) => p.id));
}
};
const removeDraftPages = async () => {
if (selectedDraftPageIds.length === 0) return;
if (previewMode) {
setError('预览模式下无法删除页面');
return;
}
setBulkDeletingDraftPages(true);
setError(null);
const deletingIds = [...selectedDraftPageIds];
const failedIds: string[] = [];
const failedNames: string[] = [];
try {
for (const pageId of deletingIds) {
try {
await deleteMindSpacePage(pageId);
if (selectedPageId === pageId) closePage();
} catch {
failedIds.push(pageId);
failedNames.push(pages.find((p) => p.id === pageId)?.title ?? pageId);
}
}
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
await load();
if (failedNames.length > 0) {
setError(`${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
}
} finally {
setBulkDeletingDraftPages(false);
}
};
const previewAsset = useMemo(
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
[assets, previewAssetId],
@@ -1362,11 +1413,6 @@ export function MindSpaceView({
广
</button>
)}
{canGenerateWithAgent(asset) && (
<button type="button" onClick={() => openAgentGenerator(asset)}>
AI
</button>
)}
{asset.publicUrl && (
<a
className="mindspace-asset-share"
@@ -1936,7 +1982,7 @@ export function MindSpaceView({
{!agentJobsLoading && agentJobsList.length === 0 && (
<div className="mindspace-empty">
<h2> AI </h2>
<p> OA AI </p>
<p> OA </p>
</div>
)}
{!agentJobsLoading && agentJobsList.length > 0 && (
@@ -2004,16 +2050,47 @@ export function MindSpaceView({
{selectedCategory.code === 'draft' ? (
pages.length > 0 ? (
<div className="mindspace-feed-grid">
{pages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
/>
))}
</div>
<>
<div className="mindspace-asset-bulkbar">
<button
type="button"
onClick={toggleAllDraftPagesSelected}
disabled={bulkDeletingDraftPages}
>
{allDraftPagesSelected ? '取消全选' : '全选'}
</button>
<span> {selectedDraftPageIds.length} </span>
{selectedDraftPageIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedDraftPageIds([])}
disabled={bulkDeletingDraftPages}
>
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={() => void removeDraftPages()}
disabled={selectedDraftPageIds.length === 0 || bulkDeletingDraftPages}
>
{bulkDeletingDraftPages ? '删除中…' : '删除选中'}
</button>
</div>
<div className="mindspace-feed-grid">
{pages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
checked={selectedDraftPageSet.has(page.id)}
onCheckChange={() => toggleDraftPageSelected(page.id)}
/>
))}
</div>
</>
) : (
<div className="mindspace-empty">
<h2>稿</h2>
+64 -24
View File
@@ -68,6 +68,7 @@ export function PageSaveDialog({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveNotice, setSaveNotice] = useState<string | null>(null);
const [duplicateResolved, setDuplicateResolved] = useState<'replace' | 'new' | null>(null);
const [privateAcknowledged, setPrivateAcknowledged] = useState(false);
const [thumbnailVersion, setThumbnailVersion] = useState(0);
const [previewScrollLock, setPreviewScrollLock] = useState(false);
@@ -151,7 +152,10 @@ export function PageSaveDialog({
const privateNeedsAck =
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
const save = async () => {
const existingPage = analysis?.existingPage ?? null;
const showDuplicatePrompt = existingPage !== null && duplicateResolved === null && !saveNotice;
const save = async (replacePageId?: string) => {
if (!title.trim()) return;
setSaving(true);
setSaveError(null);
@@ -168,10 +172,11 @@ export function PageSaveDialog({
categoryCode === 'private' && privateNeedsAck && privateAcknowledged
? privacyFindings.map((finding) => finding.id)
: undefined,
replacePageId,
});
if (result.kind === 'page') {
setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id });
setSaveNotice(replacePageId ? '已替换原有页面' : `已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode, pageId: result.page.id });
return;
}
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
@@ -202,7 +207,29 @@ export function PageSaveDialog({
{analysisLoading && <div className="page-save-state"></div>}
{analysisError && <div className="page-save-error">{analysisError}</div>}
{!analysisLoading && analysis && (
{!analysisLoading && showDuplicatePrompt && (
<div className="page-save-duplicate">
<p>{existingPage!.title}</p>
<div className="page-save-duplicate-actions">
<button
type="button"
className="page-save-duplicate-btn"
onClick={() => setDuplicateResolved('replace')}
>
</button>
<button
type="button"
className="page-save-duplicate-btn page-save-duplicate-btn-new"
onClick={() => setDuplicateResolved('new')}
>
</button>
</div>
</div>
)}
{!analysisLoading && analysis && !showDuplicatePrompt && (
<div className="page-save-layout">
<div className="page-save-form">
{isStaticHtml && analysis && (
@@ -225,6 +252,7 @@ export function PageSaveDialog({
onChange={(event) => {
const nextIndex = Number(event.target.value);
setSelectedLinkIndex(nextIndex);
setDuplicateResolved(null);
void loadAnalysis(nextIndex);
}}
>
@@ -362,26 +390,38 @@ export function PageSaveDialog({
{saveError && <div className="page-save-error">{saveError}</div>}
</div>
<div className="page-save-actions">
<button type="button" onClick={onClose} disabled={saving}>
</button>
<button
type="button"
className="page-save-primary"
onClick={() => void save()}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'}
</button>
</div>
{!showDuplicatePrompt && (
<div className="page-save-actions">
<button type="button" onClick={onClose} disabled={saving}>
</button>
<button
type="button"
className="page-save-primary"
onClick={() =>
void save(
duplicateResolved === 'replace' && existingPage ? existingPage.id : undefined,
)
}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving
? '正在保存…'
: duplicateResolved === 'replace'
? '替换并进入编辑'
: categoryCode === 'draft'
? '保存并进入编辑'
: '保存到空间'}
</button>
</div>
)}
</section>
</div>,
document.body,
+2 -2
View File
@@ -2,11 +2,11 @@ import tkmindAvatar from '../assets/tkmind-avatar.png';
type TKMindAvatarProps = {
className?: string;
size?: 'sm' | 'md';
size?: 'sm' | 'md' | 'lg';
};
export function TKMindAvatar({ className = '', size = 'sm' }: TKMindAvatarProps) {
const sizeClass = size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
const sizeClass = size === 'lg' ? 'tkmind-avatar-lg' : size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
return (
<div
+73 -53
View File
@@ -52,6 +52,7 @@ import {
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
import {
buildUserMessage,
mergeConversationSnapshot,
normalizeConversationMessages,
getDisplayText,
getToolConfirmation,
@@ -60,7 +61,12 @@ import {
isRelayServerErrorMessage,
pushMessage,
} from '../utils/message';
import { prependUnique, shouldShowNewChatTitle, sortAndTrim, touchSession } from '../utils/sessions';
import {
mergeSessionLists,
prependUnique,
shouldShowNewChatTitle,
touchSession,
} from '../utils/sessions';
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
@@ -338,7 +344,7 @@ export function useTKMindChat(
setSessionsLoading(true);
try {
const items = await listSessions();
setSessions(sortAndTrim(items));
setSessions((prev) => mergeSessionLists(prev, items));
} catch (err) {
if (err instanceof ApiError && err.status === 0) {
setError('网络不可用,无法刷新历史列表');
@@ -385,6 +391,7 @@ export function useTKMindChat(
const syncSessionMessages = useCallback(async (sessionId: string) => {
try {
const detail = await loadSessionDetail(sessionId);
setSessions((prev) => prependUnique(prev, detail.session));
if (sessionRef.current?.id !== sessionId) return;
messagesRef.current = detail.messages;
setMessages(detail.messages);
@@ -467,12 +474,14 @@ export function useTKMindChat(
}
return;
}
case 'UpdateConversation':
messagesRef.current = normalizeConversationMessages(
case 'UpdateConversation': {
const snapshot = normalizeConversationMessages(
event.conversation.filter((m) => m.metadata?.userVisible),
);
messagesRef.current = mergeConversationSnapshot(messagesRef.current, snapshot);
setMessages(messagesRef.current);
return;
}
case 'Error':
setError(event.error);
setChatState('error');
@@ -519,11 +528,22 @@ export function useTKMindChat(
sessionId,
(event) => {
const rid = activeRequestId.current;
if (rid) processEvent(event, rid, sessionId);
else if (event.type === 'ActiveRequests' && event.request_ids.length > 0) {
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
if (event.type === 'ActiveRequests') {
if (!rid && event.request_ids.length > 0) {
// SSE reconnected while agent was running — adopt the active request.
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
} else if (rid && !event.request_ids.includes(rid)) {
// Our request is no longer active — agent finished while SSE was paused.
// Sync the final state from the server.
activeRequestId.current = null;
setChatState('idle');
setPendingTool(null);
void syncSessionMessages(sessionId);
}
return;
}
if (rid) processEvent(event, rid, sessionId);
},
(err) => {
if (err instanceof ApiError && err.status === 401) return;
@@ -531,6 +551,8 @@ export function useTKMindChat(
notifyInsufficientBalance();
return;
}
// SSE 断连时如果 agent 还在跑,不打断 chatState,等重连后事件恢复
if (activeRequestId.current) return;
setError(err.message);
},
{
@@ -643,7 +665,6 @@ export function useTKMindChat(
setError(null);
const previousSessionId = readStoredSessionId(userRef.current?.id);
let sessionId: string | null = null;
let staleSession = false;
if (previousSessionId) {
@@ -667,20 +688,12 @@ export function useTKMindChat(
}
}
const freshSession = await startSession();
sessionId = freshSession.id;
writeStoredSessionId(userRef.current?.id, sessionId);
if (staleSession) {
setNotice('上次会话已失效,已为你新建对话');
}
if (cancelled) return;
await connectSessionRef.current(sessionId, {
skipResume: true,
seedSession: freshSession,
});
if (cancelled) return;
void loadProjectMemory(sessionId, false);
setChatState('idle');
void refreshSessions();
} catch (err) {
if (!cancelled) {
@@ -745,7 +758,7 @@ export function useTKMindChat(
imageUrls?: string[],
) => {
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
if (!session || (!text.trim() && normalizedImageUrls.length === 0)) return;
if (!text.trim() && normalizedImageUrls.length === 0) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
const trimmed = text.trim();
@@ -764,15 +777,41 @@ export function useTKMindChat(
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
setMessages(messagesRef.current);
setSessions((prev) => touchSession(prev, session.id, 1));
setChatState('streaming');
setError(null);
setPendingTool(null);
let activeSessionId = session?.id ?? null;
if (!activeSessionId) {
try {
const created = await startSession();
activeSessionId = created.id;
writeStoredSessionId(userRef.current?.id, created.id);
setSession(created);
setSessions((prev) => prependUnique(prev, created));
subscribeToSession(created.id);
void ensureProvider(created.id);
void loadProjectMemory(created.id, false);
void refreshSessions();
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
activeRequestId.current = null;
return;
}
} else {
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
}
try {
await sendReply(session.id, requestId, userMessage);
await sendReply(activeSessionId, requestId, userMessage);
} catch (err) {
setSessions((prev) => touchSession(prev, session.id, -1));
if (session) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
@@ -782,7 +821,7 @@ export function useTKMindChat(
activeRequestId.current = null;
}
},
[notifyInsufficientBalance, session, chatState, grantedSkills],
[notifyInsufficientBalance, session, chatState, grantedSkills, subscribeToSession, ensureProvider, loadProjectMemory, refreshSessions],
);
const stop = useCallback(async () => {
@@ -808,37 +847,18 @@ export function useTKMindChat(
const newSession = useCallback(() => {
if (chatState === 'streaming') return;
resetSessionView();
connectTokenRef.current += 1;
unsubscribeRef.current?.();
unsubscribeRef.current = null;
clearStoredSessionId(userRef.current?.id);
activeRequestId.current = null;
messagesRef.current = [];
setMessages([]);
setSession(null);
const token = connectTokenRef.current;
void (async () => {
try {
const created = await startSession();
if (token !== connectTokenRef.current) return;
writeStoredSessionId(userRef.current?.id, created.id);
setSession(created);
setSessions((prev) => prependUnique(prev, created));
void loadProjectMemory(created.id, false);
void refreshSessions();
await ensureProvider(created.id);
if (token !== connectTokenRef.current) return;
await connectSession(created.id, { showLoading: false });
} catch (err) {
if (token !== connectTokenRef.current) return;
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
return;
}
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
})();
}, [chatState, connectSession, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, resetSessionView]);
setError(null);
setPendingTool(null);
setChatState('idle');
}, [chatState]);
const refreshProjectMemory = useCallback(async () => {
if (!session || chatState === 'streaming') return;
+880 -30
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -515,6 +515,7 @@ export type ChatSaveAnalysis = {
filename: string | null;
hasHtmlContent: boolean;
privacyScan: MindSpacePrivacyScan;
existingPage: { id: string; title: string; categoryCode: string } | null;
};
export type ChatSaveResult =
+20
View File
@@ -113,6 +113,26 @@ export function normalizeConversationMessages(messages: Message[]): Message[] {
);
}
/**
* Merge an authoritative server conversation snapshot into the currently
* displayed messages without ever erasing what the user can already see.
*
* `UpdateConversation` snapshots arrive on every SSE (re)connect which on
* mobile happens constantly via the page visibility cycle. A mid-turn snapshot
* can be shorter than the live view (e.g. assistant messages not yet flagged
* userVisible), so a blind replace would blank an active conversation even
* though the backend session is alive and persisted. We therefore treat the
* snapshot as authoritative for content/order but keep any locally-displayed
* messages the snapshot hasn't caught up to yet (optimistic / in-flight tail).
* The authoritative full reload on `Finish` corrects any drift afterwards.
*/
export function mergeConversationSnapshot(current: Message[], incoming: Message[]): Message[] {
if (incoming.length === 0) return current;
const incomingIds = new Set(incoming.map((message) => message.id).filter(Boolean));
const localOnly = current.filter((message) => message.id && !incomingIds.has(message.id));
return localOnly.length ? [...incoming, ...localOnly] : incoming;
}
export function getDisplayText(message: Message): string {
if ('displayText' in message.metadata) {
return message.metadata.displayText ?? '';
+46 -5
View File
@@ -3,9 +3,18 @@ import type { Session } from '../types';
export const DEFAULT_CHAT_TITLE = 'New Chat';
function isGeneratedSessionName(name: string): boolean {
return /^20\d{6}(?:[_-]\d+)?$/.test(name.trim());
}
function isDefaultSessionName(name: string): boolean {
const trimmed = name.trim();
return !trimmed || trimmed === DEFAULT_CHAT_TITLE || trimmed === '新对话' || isGeneratedSessionName(trimmed);
}
export function shouldShowNewChatTitle(session: Session): boolean {
if (session.recipe) return false;
return !session.user_set_name && session.message_count === 0;
return !session.user_set_name && session.message_count === 0 && isDefaultSessionName(session.name);
}
export function sortAndTrim(sessions: Session[]): Session[] {
@@ -18,9 +27,39 @@ export function sortAndTrim(sessions: Session[]): Session[] {
.slice(0, appConfig.sessionMaxCount);
}
export function hasMeaningfulSessionTitle(session: Session): boolean {
if (session.user_set_name) return Boolean(session.name.trim());
if (session.recipe?.title?.trim()) return true;
return !isDefaultSessionName(session.name);
}
export function mergeSessionRecord(existing: Session | undefined, incoming: Session): Session {
if (!existing) return incoming;
const keepExistingTitle =
hasMeaningfulSessionTitle(existing) && !hasMeaningfulSessionTitle(incoming);
if (!keepExistingTitle) return { ...existing, ...incoming, id: incoming.id };
return {
...existing,
...incoming,
id: incoming.id,
name: existing.name,
user_set_name: existing.user_set_name,
recipe: existing.recipe ?? incoming.recipe,
};
}
export function mergeSessionLists(prev: Session[], incoming: Session[]): Session[] {
const previousById = new Map(prev.map((item) => [item.id, item]));
return sortAndTrim(incoming.map((item) => mergeSessionRecord(previousById.get(item.id), item)));
}
export function prependUnique(prev: Session[], session: Session): Session[] {
if (prev.some((s) => s.id === session.id)) return sortAndTrim(prev);
return sortAndTrim([session, ...prev.filter((s) => s.id !== session.id)]);
return sortAndTrim([
mergeSessionRecord(prev.find((s) => s.id === session.id), session),
...prev.filter((s) => s.id !== session.id),
]);
}
export function touchSession(sessions: Session[], sessionId: string, messageDelta = 0): Session[] {
@@ -102,9 +141,11 @@ export function groupSessionsByDate(sessions: Session[]): SessionDateGroup[] {
return groups;
}
const DEFAULT_SESSION_NAMES = new Set([DEFAULT_CHAT_TITLE, '新对话']);
export function getSessionListLabel(session: Session): string {
const displayName = getSessionDisplayName(session);
if (displayName !== DEFAULT_CHAT_TITLE && displayName !== '新对话') {
const displayName = getSessionDisplayName(session).trim();
if (displayName && !DEFAULT_SESSION_NAMES.has(displayName) && !isGeneratedSessionName(displayName)) {
return displayName;
}
if (session.message_count > 0) {
+55 -5
View File
@@ -266,7 +266,7 @@ export async function buildVisionPayload({
};
}
export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, llmProviderService, localFetchAsset, subscriptionService }) {
export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, llmProviderService, localFetchAsset, subscriptionService, sessionSnapshotService }) {
const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
const primaryTarget = targets[0] ?? apiTarget ?? '';
let rrIdx = 0;
@@ -296,6 +296,45 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
}
}
function isGeneratedSessionName(name) {
const normalized = typeof name === 'string' ? name.trim() : '';
return /^20\d{6}(?:[_-]\d+)?$/.test(normalized);
}
// A session "name" that carries no real topic: empty, or Goose's default
// placeholder before its describe step has run.
function hasDefaultSessionName(session) {
if (session?.user_set_name) return false;
const name = typeof session?.name === 'string' ? session.name.trim() : '';
return name === '' || name === 'New Chat' || name === '新对话' || isGeneratedSessionName(name);
}
// For sessions still carrying a default name, fill in a title derived from the
// first user message (kept in the snapshot cache) so the history list shows a
// meaningful label instead of "会话 <id>". Best-effort: never blocks the list.
async function enrichSessionHistory(sessions) {
if (!sessionSnapshotService?.isEnabled?.()) return;
const needsSummary = sessions.filter(
(session) => hasDefaultSessionName(session) || Number(session?.message_count ?? 0) === 0,
);
if (needsSummary.length === 0) return;
try {
const summaries = await sessionSnapshotService.getHistorySummaries(needsSummary.map((s) => s.id));
for (const session of needsSummary) {
const summary = summaries.get(session.id);
if (!summary) continue;
if (summary.title && hasDefaultSessionName(session)) {
session.name = summary.title;
}
if (Number(session?.message_count ?? 0) === 0 && Number(summary.messageCount ?? 0) > 0) {
session.message_count = Number(summary.messageCount);
}
}
} catch {
// Non-fatal: fall back to the client-side label.
}
}
async function pickTarget() {
if (targets.length <= 1) return primaryTarget;
for (let i = 0; i < targets.length; i += 1) {
@@ -632,7 +671,10 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
if (healthyTargets < targets.length) {
res.setHeader('X-TKMind-Degraded', '1');
}
res.json({ sessions: [...sessionsById.values()] });
const sessions = [...sessionsById.values()];
await enrichSessionHistory(sessions);
res.json({ sessions });
} catch (err) {
res.status(500).json({
message: sanitizeUserFacingProxyMessage(
@@ -725,6 +767,13 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
});
const source = Readable.fromWeb(upstream.body);
// 每 20s 发一个注释行保持连接,防止 nginx/代理因静默超时切断 SSE
const keepalive = setInterval(() => {
if (!res.writableEnded) res.write(': keepalive\n\n');
}, 20000);
const stopKeepalive = () => clearInterval(keepalive);
billingTransform.on('data', (chunk) => {
res.write(chunk);
if (pendingBalance != null) {
@@ -732,9 +781,10 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
pendingBalance = null;
}
});
billingTransform.on('end', () => res.end());
billingTransform.on('error', () => res.end());
source.on('error', () => res.end());
billingTransform.on('end', () => { stopKeepalive(); res.end(); });
billingTransform.on('error', () => { stopKeepalive(); res.end(); });
source.on('error', () => { stopKeepalive(); res.end(); });
req.on('close', stopKeepalive);
source.pipe(billingTransform);
} catch (err) {
res.status(502).json({