From 8264e71f9e7581c2bc4e69352d46346345c63d55 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 27 Jun 2026 21:57:21 +0800 Subject: [PATCH] feat: release chat and mindspace UI updates --- db.mjs | 7 + mindspace-pages.mjs | 61 ++ package-lock.json | 56 +- package.json | 2 + schema.sql | 1 + server.mjs | 70 +- session-snapshot.mjs | 190 ++++- src/api/client.ts | 21 + src/components/ChatPanel.tsx | 73 +- src/components/ChatSharePreviewModal.tsx | 111 +++ src/components/ChatView.tsx | 7 +- src/components/ChatWelcomePanel.tsx | 110 +++ src/components/HistorySidebar.tsx | 56 +- src/components/MessageList.tsx | 100 ++- src/components/MindSpaceFeedCard.tsx | 17 + src/components/MindSpaceView.tsx | 109 ++- src/components/PageSaveDialog.tsx | 88 ++- src/components/TKMindAvatar.tsx | 4 +- src/hooks/useTKMindChat.ts | 126 ++-- src/index.css | 910 ++++++++++++++++++++++- src/types.ts | 1 + src/utils/message.ts | 20 + src/utils/sessions.ts | 51 +- tkmind-proxy.mjs | 60 +- 24 files changed, 2042 insertions(+), 209 deletions(-) create mode 100644 src/components/ChatSharePreviewModal.tsx create mode 100644 src/components/ChatWelcomePanel.tsx diff --git a/db.mjs b/db.mjs index 93ed4f8..47cc91c 100644 --- a/db.mjs +++ b/db.mjs @@ -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)`); diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 92fca08..76d30f0 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -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} 个版本`, diff --git a/package-lock.json b/package-lock.json index 56a7fce..4d7e980 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3a963fe..87e15a2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/schema.sql b/schema.sql index a78fbb7..b30407f 100644 --- a/schema.sql +++ b/schema.sql @@ -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 '', diff --git a/server.mjs b/server.mjs index 9193611..2181f1e 100644 --- a/server.mjs +++ b/server.mjs @@ -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); diff --git a/session-snapshot.mjs b/session-snapshot.mjs index a5c33b7..0a1b933 100644 --- a/session-snapshot.mjs +++ b/session-snapshot.mjs @@ -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; 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 }; } diff --git a/src/api/client.ts b/src/api/client.ts index 00e30e4..a15cc7b 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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 { let res: Response; try { @@ -754,6 +773,7 @@ export async function saveChatMessageAsPage(input: { categoryCode?: MindSpaceSaveCategory; selectedLinkIndex?: number; acknowledgedFindingIds?: string[]; + replacePageId?: string; }): Promise { 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, }), }, ); diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 25fa6c6..c707182 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -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(null); const [voiceStopSignal, setVoiceStopSignal] = useState(0); + const [randomPrompt] = useState( + () => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)], + ); const imageInputRef = useRef(null); const bottomRef = useRef(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 ( <> -
+
setPageSource(message)} publishUserId={user?.id} publishUsername={user?.username} + sessionId={session?.id} compact={compact} /> {!compact && ( @@ -369,7 +406,7 @@ export function ChatPanel({ /> )} -