From 637b32515adc4c0a82b8f5b099ac9eac5bf18002 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 29 Jun 2026 09:45:52 +0800 Subject: [PATCH 1/8] fix(mindspace): sync nested OA files and count workspace assets Recursive workspace sync now imports files in oa/private/public subfolders, references workspace paths without duplicating storage, and includes workspace-sourced assets in category itemCount. Refresh counts when leaving a category view. Co-authored-by: Cursor --- mindspace-assets.mjs | 19 ++++- mindspace-workspace-sync.mjs | 135 +++++++++++++++++++----------- mindspace-workspace-sync.test.mjs | 130 ++++++++++++++++++++++++++-- mindspace.mjs | 1 - mindspace.test.mjs | 1 + src/components/MindSpaceView.tsx | 1 + user-space.mjs | 3 +- workspace-storage.mjs | 41 +++++++++ 8 files changed, 274 insertions(+), 57 deletions(-) create mode 100644 workspace-storage.mjs diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index f69312c..f1477a9 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -13,6 +13,7 @@ import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs'; import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from './user-publish.mjs'; import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs'; import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs'; +import { resolveWorkspaceStoragePath, isWorkspaceStorageKey } from './workspace-storage.mjs'; const ALLOWED_EXTENSIONS = new Map([ ['.txt', 'text/plain'], @@ -297,6 +298,17 @@ export function createAssetService(pool, options = {}) { }; const resolveReadableStoragePath = async (storageKey) => { + if (h5Root && isWorkspaceStorageKey(storageKey)) { + const workspacePath = resolveWorkspaceStoragePath(h5Root, storageKey); + if (workspacePath) { + try { + await fs.stat(workspacePath); + return workspacePath; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + } let lastError = null; for (const candidate of resolveStoragePathCandidates(storageKey)) { const absolutePath = absoluteStoragePath(candidate); @@ -661,7 +673,12 @@ export function createAssetService(pool, options = {}) { const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => { if (syncWorkspace && categoryCode && ['oa', 'private', 'public'].includes(categoryCode)) { - await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch(() => {}); + await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch((error) => { + console.warn( + `[MindSpace] workspace sync failed (${categoryCode}):`, + error?.message ?? error, + ); + }); } const filters = [`a.user_id = ?`, `a.status <> 'deleted'`]; const params = [userId]; diff --git a/mindspace-workspace-sync.mjs b/mindspace-workspace-sync.mjs index 131d730..d47df32 100644 --- a/mindspace-workspace-sync.mjs +++ b/mindspace-workspace-sync.mjs @@ -9,56 +9,99 @@ import { } from './user-space.mjs'; import { assetInternals } from './mindspace-assets.mjs'; import { runBasicFileScan } from './mindspace-scan.mjs'; +import { buildWorkspaceStorageKey } from './workspace-storage.mjs'; const SKIP_FILENAMES = new Set(['index.html', '.tkmindhints', '.goosehints', '.ls_output']); +const SKIP_ZONE_DIR_NAMES = new Set([ + 'node_modules', + '.venv', + '.venv2', + '__pycache__', + '.git', + 'dist', + 'build', + '.next', + 'target', + 'backend', + 'frontend', +]); +/** 子目录内常见的项目/脚手架文件,不同步到 OA 资产库 */ +const NESTED_SKIP_BASENAMES = new Set([ + 'README.md', + 'requirements.txt', + 'package.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +]); function asNumber(value) { return Number(value ?? 0); } -export function shouldSyncWorkspaceFilename(filename) { - const normalized = String(filename ?? '').trim(); - if (!normalized || normalized.startsWith('.')) return false; - if (SKIP_FILENAMES.has(normalized)) return false; - if (normalized.endsWith('.thumbnail.svg')) return false; - if (normalized.endsWith('.sh')) return false; - return assetInternals.expectedMimeType(normalized) !== null; +export function normalizeWorkspaceRelativePath(relativePath) { + const normalized = String(relativePath ?? '') + .normalize('NFKC') + .trim() + .replace(/\\/g, '/'); + if (!normalized || normalized.startsWith('/') || normalized.includes('\0')) return null; + const parts = normalized.split('/').filter(Boolean); + if (parts.some((part) => part === '.' || part === '..')) return null; + if (parts.some((part) => part.startsWith('.'))) return null; + return parts.join('/').slice(0, 255); } -export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) { - if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return []; - const zoneDir = resolveZoneDir(workspaceRoot, categoryCode); +export function shouldSyncWorkspaceFilename(filename) { + const relativePath = normalizeWorkspaceRelativePath(filename); + if (!relativePath) return false; + const basename = path.posix.basename(relativePath); + if (!basename || basename.startsWith('.')) return false; + if (SKIP_FILENAMES.has(basename)) return false; + if (basename.endsWith('.thumbnail.svg')) return false; + if (basename.endsWith('.sh')) return false; + if (relativePath.includes('/') && NESTED_SKIP_BASENAMES.has(basename)) return false; + return assetInternals.expectedMimeType(basename) !== null; +} + +async function walkWorkspaceZoneDir(zoneDir, relativePrefix, files) { let entries; try { entries = await fsPromises.readdir(zoneDir, { withFileTypes: true }); } catch { - return []; + return; } - const files = []; for (const entry of entries) { - if (!entry.isFile()) continue; - if (!shouldSyncWorkspaceFilename(entry.name)) continue; + if (entry.name.startsWith('.')) continue; const absolutePath = path.join(zoneDir, entry.name); + if (entry.isDirectory()) { + if (SKIP_ZONE_DIR_NAMES.has(entry.name)) continue; + const nextPrefix = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name; + await walkWorkspaceZoneDir(absolutePath, nextPrefix, files); + continue; + } + if (!entry.isFile()) continue; + const filename = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name; + if (!shouldSyncWorkspaceFilename(filename)) continue; const stat = await fsPromises.stat(absolutePath); files.push({ - filename: entry.name, + filename, absolutePath, sizeBytes: stat.size, mtimeMs: stat.mtimeMs, }); } +} + +export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) { + if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return []; + const zoneDir = resolveZoneDir(workspaceRoot, categoryCode); + const files = []; + await walkWorkspaceZoneDir(zoneDir, '', files); return files; } export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) { - const absoluteStoragePath = (storageKey) => { - const resolved = path.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) { - throw new Error('存储路径越界'); - } - return resolved; - }; - + void storageRoot; const loadExistingAssets = async (userId, categoryId) => { const [rows] = await pool.query( `SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status @@ -88,7 +131,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt const importWorkspaceFile = async (userId, category, file, buffer) => { const conn = await pool.getConnection(); - let finalPath; try { await conn.beginTransaction(); const [spaces] = await conn.query( @@ -123,17 +165,11 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const assetId = idFactory(); const versionId = idFactory(); - const finalStorageKey = path.posix.join( - 'users', + const finalStorageKey = buildWorkspaceStorageKey( userId, - 'assets', - assetId, - 'versions', - versionId, + category.category_code, + file.filename, ); - finalPath = absoluteStoragePath(finalStorageKey); - await fsPromises.mkdir(path.dirname(finalPath), { recursive: true }); - await fsPromises.writeFile(finalPath, buffer, { flag: 'wx' }); const now = Date.now(); const visibility = category.category_code === 'public' ? 'public_candidate' : 'private'; @@ -188,7 +224,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt return { action: 'imported', assetId, filename: file.filename, checksum }; } catch (error) { await conn.rollback(); - if (finalPath) await fsPromises.rm(finalPath, { force: true }).catch(() => {}); throw error; } finally { conn.release(); @@ -197,7 +232,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt const updateWorkspaceFile = async (userId, category, existing, file, buffer) => { const conn = await pool.getConnection(); - let finalPath; try { await conn.beginTransaction(); const [spaces] = await conn.query( @@ -238,17 +272,11 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt ); const versionNo = asNumber(versionRows[0]?.max_version) + 1; const versionId = idFactory(); - const finalStorageKey = path.posix.join( - 'users', + const finalStorageKey = buildWorkspaceStorageKey( userId, - 'assets', - existing.id, - 'versions', - versionId, + category.category_code, + file.filename, ); - finalPath = absoluteStoragePath(finalStorageKey); - await fsPromises.mkdir(path.dirname(finalPath), { recursive: true }); - await fsPromises.writeFile(finalPath, buffer, { flag: 'wx' }); const now = Date.now(); await conn.query( @@ -298,7 +326,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt return { action: 'updated', assetId: existing.id, filename: file.filename, checksum }; } catch (error) { await conn.rollback(); - if (finalPath) await fsPromises.rm(finalPath, { force: true }).catch(() => {}); throw error; } finally { conn.release(); @@ -390,10 +417,20 @@ export function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceB const watchZoneDir = (dirKey, zoneDir, categoryCode) => { try { - fs.watch(zoneDir, (_event, filename) => { - if (!filename || !shouldSyncWorkspaceFilename(filename)) return; - schedule(dirKey, categoryCode); - }); + fs.watch( + zoneDir, + { recursive: true }, + (_event, filename) => { + if (!filename) { + schedule(dirKey, categoryCode); + return; + } + const normalized = String(filename).replace(/\\/g, '/'); + if (shouldSyncWorkspaceFilename(normalized)) { + schedule(dirKey, categoryCode); + } + }, + ); } catch { // ignore unsupported watch targets } diff --git a/mindspace-workspace-sync.test.mjs b/mindspace-workspace-sync.test.mjs index 02bafd1..09f19c5 100644 --- a/mindspace-workspace-sync.test.mjs +++ b/mindspace-workspace-sync.test.mjs @@ -12,22 +12,32 @@ import { resolveUserWorkspaceRoot } from './user-space.mjs'; test('shouldSyncWorkspaceFilename skips helper files', () => { assert.equal(shouldSyncWorkspaceFilename('端午感怀.docx'), true); + assert.equal(shouldSyncWorkspaceFilename('暑假实习报告/暑假生活实习报告.docx'), true); + assert.equal(shouldSyncWorkspaceFilename('诗歌散文/夏日的诗篇.md'), true); + assert.equal(shouldSyncWorkspaceFilename('sales-manager/README.md'), false); assert.equal(shouldSyncWorkspaceFilename('index.html'), false); assert.equal(shouldSyncWorkspaceFilename('note.thumbnail.svg'), false); + assert.equal(shouldSyncWorkspaceFilename('../escape.docx'), false); }); -test('listWorkspaceZoneFiles returns only supported top-level zone files', async () => { +test('listWorkspaceZoneFiles returns supported top-level and nested zone files', async () => { const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-')); const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); - await fs.mkdir(path.join(workspace, 'oa'), { recursive: true }); + await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true }); + await fs.mkdir(path.join(workspace, 'oa', '诗歌散文'), { recursive: true }); await fs.writeFile(path.join(workspace, 'oa', 'report.csv'), 'a,b\n1,2\n'); await fs.writeFile(path.join(workspace, 'oa', 'index.html'), ''); await fs.writeFile(path.join(workspace, 'oa', 'note.txt'), 'hello'); + await fs.writeFile(path.join(workspace, 'oa', '诗歌散文', '夏日的诗篇.md'), '# 夏日的诗篇\n'); + await fs.writeFile( + path.join(workspace, 'oa', '暑假实习报告', '暑假生活实习报告.docx'), + 'fake docx', + ); const files = await listWorkspaceZoneFiles(workspace, 'oa'); assert.deepEqual( files.map((item) => item.filename).sort(), - ['note.txt', 'report.csv'], + ['note.txt', 'report.csv', '暑假实习报告/暑假生活实习报告.docx', '诗歌散文/夏日的诗篇.md'].sort(), ); }); @@ -145,6 +155,116 @@ test('syncUserWorkspace imports new workspace files into asset library', async ( assert.equal(state.assets.length, 1); assert.equal(state.assets[0].original_filename, 'memo.txt'); assert.ok(state.versions.length === 1); - const stored = path.join(storageRoot, state.versions[0].storage_key); - assert.equal(await fs.readFile(stored, 'utf8'), 'hello workspace\n'); + assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/oa\/memo\.txt$/); + assert.equal(await fs.readFile(path.join(workspace, 'oa', 'memo.txt'), 'utf8'), 'hello workspace\n'); +}); + +test('syncUserWorkspace imports nested workspace files into asset library', async () => { + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-nested-')); + const storageRoot = path.join(h5Root, 'data', 'mindspace'); + const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); + await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true }); + await fs.writeFile(path.join(workspace, 'oa', '暑假实习报告', 'report.csv'), 'a,b\n1,2\n'); + + const state = { + categories: [ + { id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' }, + ], + spaces: [ + { + id: 'space-1', + user_id: 'user-1', + quota_bytes: 5 * 1024 * 1024, + used_bytes: 0, + reserved_bytes: 0, + status: 'active', + }, + ], + assets: [], + versions: [], + }; + + let nextId = 0; + const pool = { + async query(sql, params = []) { + if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) { + const category = state.categories.find( + (item) => item.user_id === params[0] && item.category_code === params[1], + ); + return [category ? [category] : []]; + } + if (sql.includes('FROM h5_assets')) { + const assets = state.assets.filter((item) => { + if (item.user_id !== params[0] || item.category_id !== params[1]) return false; + if (sql.includes("status <> 'deleted'") && item.status === 'deleted') return false; + if (sql.includes("status = 'deleted'") && item.status !== 'deleted') return false; + if (sql.includes("source_type = 'workspace'") && item.source_type !== 'workspace') { + return false; + } + return true; + }); + return [assets]; + } + return [[]]; + }, + async getConnection() { + return { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + async query(sql, params = []) { + if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) { + const space = state.spaces.find( + (item) => item.id === params[0] && item.user_id === params[1], + ); + return [space ? [space] : []]; + } + if (sql.includes('INSERT INTO h5_assets')) { + state.assets.push({ + id: params[0], + user_id: params[1], + category_id: params[3], + original_filename: params[6], + checksum: params[10], + size_bytes: params[9], + current_version_id: params[8], + status: params[13], + source_type: 'workspace', + }); + return [[]]; + } + if (sql.includes('INSERT INTO h5_asset_versions')) { + state.versions.push({ + id: params[0], + asset_id: params[1], + storage_key: params[2], + scan_status: params[7], + }); + return [[]]; + } + if (sql.includes('used_bytes = used_bytes +')) { + state.spaces[0].used_bytes += params[0]; + return [[]]; + } + if (sql.includes('COALESCE(MAX(version_no)')) { + return [[{ max_version: 0 }]]; + } + return pool.query(sql, params); + }, + }; + }, + }; + + const sync = createWorkspaceAssetSync({ + pool, + storageRoot, + h5Root, + maxFileBytes: 1024 * 1024, + idFactory: () => `id-${++nextId}`, + }); + + const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'oa' }); + assert.equal(result.imported, 1); + assert.equal(state.assets[0].original_filename, '暑假实习报告/report.csv'); }); diff --git a/mindspace.mjs b/mindspace.mjs index a67c2c9..ef13928 100644 --- a/mindspace.mjs +++ b/mindspace.mjs @@ -179,7 +179,6 @@ export function createMindSpaceService(pool, options = {}) { ON a.category_id = c.id AND a.user_id = c.user_id AND a.status <> 'deleted' - AND a.source_type = 'upload' LEFT JOIN h5_page_records p ON p.category_id = c.id AND p.user_id = c.user_id AND p.status <> 'deleted' LEFT JOIN h5_publish_records pr diff --git a/mindspace.test.mjs b/mindspace.test.mjs index 813d07d..9f963e9 100644 --- a/mindspace.test.mjs +++ b/mindspace.test.mjs @@ -106,6 +106,7 @@ test('getSpace scopes space and categories to the authenticated user', async () assert.equal(space.categories[0].code, 'private'); assert.deepEqual(calls[0].params, ['user-1']); assert.deepEqual(calls[1].params, ['user-1', 'space-1']); + assert.doesNotMatch(calls[1].sql, /source_type\s*=\s*'upload'/); }); test('getSpace includes schedule snapshot when schedule service is available', async () => { diff --git a/src/components/MindSpaceView.tsx b/src/components/MindSpaceView.tsx index 0abafb7..a47e354 100644 --- a/src/components/MindSpaceView.tsx +++ b/src/components/MindSpaceView.tsx @@ -755,6 +755,7 @@ export function MindSpaceView({ setPendingDeleteId(null); setSelectedAssetIds([]); setImagePage(0); + void refreshSpaceQuietly(); routeSync?.pushHome(); }; diff --git a/user-space.mjs b/user-space.mjs index 9ee271a..1cfe401 100644 --- a/user-space.mjs +++ b/user-space.mjs @@ -106,8 +106,9 @@ ${zoneLines.join('\n')} ## 工作区文件与 OA 界面 -- 写入 \`oa/\`、\`private/\`、\`public/\` 根目录的支持类型文件(docx、csv、pdf、图片等)会**自动同步**到 MindSpace 资产库 +- 写入 \`oa/\`、\`private/\`、\`public/\` 下任意层级的支持类型文件(docx、md、txt、csv、pdf、图片等)会**自动同步**到 MindSpace 资产库(含子目录,如 \`oa/诗歌散文/夏日的诗篇.md\`) - 打开对应分区或保存文件后会出现在界面中,可直接预览或下载 +- 生成 docx/pdf 等 OA 资料时,可直接 \`write_file\` 到 \`oa/<子目录>/<文件名>\`,无需手动复制到根目录 - 用户上传的文件仍以界面入库为准,并镜像到上述分区 `; } diff --git a/workspace-storage.mjs b/workspace-storage.mjs new file mode 100644 index 0000000..9176bf3 --- /dev/null +++ b/workspace-storage.mjs @@ -0,0 +1,41 @@ +import path from 'node:path'; +import { resolveUserWorkspaceRoot, resolveZoneFilePath } from './user-space.mjs'; + +export const WORKSPACE_STORAGE_PREFIX = 'workspace://'; + +export function buildWorkspaceStorageKey(userId, categoryCode, relativeFilename) { + const category = String(categoryCode ?? '').trim(); + const relativePath = String(relativeFilename ?? '').replace(/\\/g, '/').replace(/^\/+/, ''); + if (!userId || !category || !relativePath) { + throw new Error('invalid workspace storage key parts'); + } + if (relativePath.split('/').some((part) => part === '..' || part === '.')) { + throw new Error('invalid workspace relative path'); + } + return `${WORKSPACE_STORAGE_PREFIX}${userId}/${category}/${relativePath}`; +} + +export function isWorkspaceStorageKey(storageKey) { + return String(storageKey ?? '').startsWith(WORKSPACE_STORAGE_PREFIX); +} + +export function resolveWorkspaceStoragePath(h5Root, storageKey) { + if (!h5Root || !isWorkspaceStorageKey(storageKey)) return null; + const rest = storageKey.slice(WORKSPACE_STORAGE_PREFIX.length); + const slash = rest.indexOf('/'); + if (slash <= 0) return null; + const userId = rest.slice(0, slash); + const remainder = rest.slice(slash + 1); + const slash2 = remainder.indexOf('/'); + if (slash2 <= 0) return null; + const categoryCode = remainder.slice(0, slash2); + const relativeFilename = remainder.slice(slash2 + 1); + if (!relativeFilename) return null; + const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); + const resolved = path.resolve(resolveZoneFilePath(workspaceRoot, categoryCode, relativeFilename)); + const zoneRoot = path.resolve(resolveZoneFilePath(workspaceRoot, categoryCode, '')); + if (resolved !== zoneRoot && !resolved.startsWith(`${zoneRoot}${path.sep}`)) { + return null; + } + return resolved; +} From d67adc1df8cd0950b4977f47f458b9b4e5ca6769 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 29 Jun 2026 11:19:20 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(skills):=20=E7=A6=81=E6=AD=A2=20HTML=20?= =?UTF-8?q?base64=20=E5=86=85=E5=B5=8C=20docx=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20docx-generate=20=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免 Agent 在下载页用 data URI 嵌入 Word 导致文件截断乱码;规范公网下载须单独落盘并用相对路径链接。 Co-authored-by: Cursor --- skills-registry.mjs | 1 + skills/docx-generate/SKILL.md | 80 +++++++++++ skills/docx-generate/generate_docx.py | 191 ++++++++++++++++++++++++++ skills/static-page-publish/SKILL.md | 7 + user-publish.mjs | 1 + 5 files changed, 280 insertions(+) create mode 100644 skills/docx-generate/SKILL.md create mode 100644 skills/docx-generate/generate_docx.py diff --git a/skills-registry.mjs b/skills-registry.mjs index 900cddf..0c18873 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -17,6 +17,7 @@ export const DEFAULT_USER_SKILLS = { 'form-builder': true, 'table-viewer': true, 'product-campaign-page': true, + 'docx-generate': true, [PUBLISH_SKILL_NAME]: false, }; diff --git a/skills/docx-generate/SKILL.md b/skills/docx-generate/SKILL.md new file mode 100644 index 0000000..4dbf011 --- /dev/null +++ b/skills/docx-generate/SKILL.md @@ -0,0 +1,80 @@ +--- +name: docx-generate +description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word .docx,无需 python-docx 或 docx_tool +--- + +# Word 文档生成(.docx) + +## 重要说明 + +- **平台没有 `docx_tool` / `update_doc`**,不要编造或调用不存在的工具 +- **`write_file` 不能写二进制 .docx**,不要用 write_file 假装生成 Word +- **禁止**在 HTML 里用 `data:application/...;base64,...` 内嵌 docx(极易被截断损坏,下载后乱码) +- 需要公网下载时:先用本脚本生成 `.docx` 落盘,再在 HTML 里用**相对路径**链接(如 `public/方案.docx`) +- 生产沙箱通常**不能 pip install**,优先用本技能自带的 **stdlib 脚本** +- 生成后必须用 **`list_dir oa/`**(或目标目录)确认文件已落盘,再告诉用户 + +## 何时使用 + +- 用户要 Word / docx / .doc 文档(输出 `.docx`) +- 需要保存到 `oa/`、`private/` 等分区 + +## 推荐命令 + +技能目录内有 `generate_docx.py`(仅依赖 Python 3 标准库): + +```bash +python3 .agents/skills/docx-generate/generate_docx.py --json - --output oa/报告.docx <<'EOF' +{ + "title": "文档标题", + "sections": [ + { + "heading": "一、章节标题", + "paragraphs": ["段落一", "段落二"], + "table": { + "headers": ["列1", "列2"], + "rows": [["A", "B"], ["C", "D"]] + } + } + ] +} +EOF +``` + +然后: + +```bash +list_dir oa +``` + +## JSON 字段 + +| 字段 | 说明 | +|------|------| +| `title` | 文档主标题(可选) | +| `sections[]` | 章节数组 | +| `sections[].heading` | 章节标题 | +| `sections[].paragraphs` | 字符串段落列表 | +| `sections[].table.headers` | 表头 | +| `sections[].table.rows` | 表格行 | + +**禁止**把 `` 等 OOXML 标签写进 `paragraphs` 文本里;表格只能走 `table` 字段。 + +## 公网下载页(HTML + docx) + +用户要「打开链接下载 Word」时: + +1. 用本脚本生成 docx(建议 `public/文件名.docx` 或 `oa/文件名.docx` 再复制到 `public/`) +2. 用 `static-page-publish` 写下载页,`href` 指向**同目录相对路径**: + +```html +下载 Word 文档 +``` + +3. **禁止** `href="data:...;base64,..."` 嵌入 docx + +## 备选方案 + +1. **用户要在线查看、可分享**:用 `static-page-publish` 技能写 `public/xxx.html` +2. **环境有 python-docx**(工作区 `.venv` 已装):仍可用,但生成后必须 `list_dir` 验证 +3. **禁止**在未验证文件存在时宣称「已生成 docx」 diff --git a/skills/docx-generate/generate_docx.py b/skills/docx-generate/generate_docx.py new file mode 100644 index 0000000..20c1945 --- /dev/null +++ b/skills/docx-generate/generate_docx.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Generate a minimal valid .docx using only Python stdlib (zipfile + XML).""" + +from __future__ import annotations + +import argparse +import json +import sys +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types" +REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +CP_NS = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties" +DC_NS = "http://purl.org/dc/elements/1.1" + +CONTENT_TYPES = f""" + + + + + +""" + +ROOT_RELS = f""" + + + +""" + +DOCUMENT_RELS = f""" +""" + + +def xml_text(text: str) -> str: + return escape(str(text or "")) + + +def run(text: str, bold: bool = False) -> str: + props = "" if bold else "" + return ( + f'{props}' + f'{xml_text(text)}' + ) + + +def paragraph(parts: str, style: str | None = None) -> str: + ppr = f'' if style else "" + return f"{ppr}{parts}" + + +def heading(text: str) -> str: + return paragraph(run(text, bold=True)) + + +def body_paragraph(text: str) -> str: + return paragraph(run(text)) + + +def is_ooxml_fragment(text: str) -> bool: + stripped = str(text or "").strip() + return stripped.startswith(" str: + col_count = max(len(headers), max((len(r) for r in rows), default=0)) + if col_count == 0: + return "" + + col_width = max(1800, 9000 // col_count) + + def cell(text: str) -> str: + return ( + f'' + f"{paragraph(run(text))}" + ) + + def table_row(cells: list[str]) -> str: + padded = cells + [""] * (col_count - len(cells)) + return f"{''.join(cell(c) for c in padded[:col_count])}" + + grid_cols = "".join(f'' for _ in range(col_count)) + tbl_pr = ( + "" + '' + "" + '' + '' + '' + '' + '' + '' + "" + "" + ) + # Table must be a direct child of w:body — never wrap w:tbl inside w:p. + parts = ["", tbl_pr, f"{grid_cols}"] + if headers: + parts.append(table_row(headers)) + parts.extend(table_row(r) for r in rows) + parts.append("") + return "".join(parts) + + +def build_document_xml(title: str, sections: list[dict]) -> str: + body: list[str] = [] + if title: + body.append(heading(title)) + body.append(body_paragraph("")) + + for section in sections: + heading_text = section.get("heading") or section.get("title") + if heading_text: + body.append(heading(str(heading_text))) + for para in section.get("paragraphs") or []: + text = str(para).strip() + if text and not is_ooxml_fragment(text): + body.append(body_paragraph(text)) + table = section.get("table") + if isinstance(table, dict): + block = table_block( + list(table.get("headers") or []), + [list(r) for r in (table.get("rows") or [])], + ) + if block: + body.append(block) + body.append(body_paragraph("")) + + sect_pr = ( + f'' + f'' + ) + inner = "".join(body) + sect_pr + return ( + f'' + f'' + f"{inner}" + ) + + +def core_xml(title: str) -> str: + return ( + f'' + f'' + f"{xml_text(title)}" + f"" + ) + + +def write_docx(output: Path, title: str, sections: list[dict]) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + document_xml = build_document_xml(title, sections) + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", CONTENT_TYPES) + zf.writestr("_rels/.rels", ROOT_RELS) + zf.writestr("word/_rels/document.xml.rels", DOCUMENT_RELS) + zf.writestr("word/document.xml", document_xml) + zf.writestr("docProps/core.xml", core_xml(title)) + + +def load_payload(args: argparse.Namespace) -> dict: + if args.json: + source = sys.stdin.read() if args.json == "-" else Path(args.json).read_text(encoding="utf-8") + return json.loads(source) + if args.title: + return {"title": args.title, "sections": [{"paragraphs": args.paragraph or []}]} + raise SystemExit("需要 --json (或 - 读 stdin),或 --title") + + +def main() -> None: + parser = argparse.ArgumentParser(description="用 Python 标准库生成 .docx(无需 python-docx)") + parser.add_argument("--output", required=True, help="输出路径,如 oa/报告.docx") + parser.add_argument("--json", help="JSON 内容文件;传 - 则从 stdin 读取") + parser.add_argument("--title", help="仅标题 + --paragraph 时的文档标题") + parser.add_argument("--paragraph", action="append", help="配合 --title 追加段落") + args = parser.parse_args() + + payload = load_payload(args) + title = str(payload.get("title") or "") + sections = list(payload.get("sections") or []) + output = Path(args.output) + write_docx(output, title, sections) + size = output.stat().st_size + print(f"已生成 {output}({size} 字节)") + + +if __name__ == "__main__": + main() diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index 65ac9ff..f316b3f 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -19,6 +19,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 3. 读 CSV/列目录:用工作区内的 `shell`(`ls oa/`、`cat file.csv`)或 `tree`;**禁止**用公网 URL 代替 4. 公网链接**仅**用于让用户浏览器打开已发布的 HTML,不能用来列目录或读数据文件 5. 静态文件保存即可访问,**无需重启** +6. 页面需提供 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏) 详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。 @@ -75,3 +76,9 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 **禁止**省略 mindspace-cover 或填写与页面无关的通用配色;促销/运动/品牌页必须写明 `tag`、`accent` 和 `cover`。 本地对比示例:`node scripts/thumbnail-preview-demo.mjs` → `/thumbnail-demo/` + +## 附带文件下载(Word / PDF) + +- 二进制文件用 `docx-generate` 脚本或平台允许的方式**单独生成**,保存到 `public/`(或 `oa/` 再复制到 `public/`) +- 下载按钮示例:`下载文档`(与 HTML 同目录时用文件名即可) +- **禁止** `` 内嵌 docx/pdf diff --git a/user-publish.mjs b/user-publish.mjs index 274840b..16e93d2 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -203,6 +203,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 - 不要写入当前工作区以外的任何目录 - shell 仅用于本目录内整理文件/简单脚本;不要 \`rm -rf\` 越界路径、不要安装系统级依赖 +- **禁止**在 HTML 中用 \`data:...;base64,...\` 内嵌 Word/PDF;二进制文件单独落盘后用相对路径链接(见 \`docx-generate\` 技能) ## 示例 From 0187f26c5b0f43fd94af6cda31b590f2ce81ee4c Mon Sep 17 00:00:00 2001 From: john Date: Mon, 29 Jun 2026 11:23:37 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(release):=20=E5=B0=86=20skills/=20?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=89=93=E5=85=A5=20Portal=20runtime=20?= =?UTF-8?q?=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产 Agent 需从 live 目录读取平台技能(含 docx-generate),无源码 runtime 必须携带 skills 树。 Co-authored-by: Cursor --- scripts/build-portal-runtime.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 15ad61d..a5ad9fe 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -131,6 +131,12 @@ async function copyRuntimeAssets() { if (await exists(schemaFile)) { await fs.copyFile(schemaFile, path.join(runtimeRoot, 'schema.sql')); } + + const skillsDir = path.join(root, 'skills'); + if (await exists(skillsDir)) { + console.log('==> 拷贝平台 skills 目录'); + await copyDir(skillsDir, path.join(runtimeRoot, 'skills')); + } } async function copyNodeModules() { @@ -233,6 +239,8 @@ async function writeMetadata() { 'Bundled alongside server.mjs (required for sandbox-fs MCP):', ' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)', '', + 'Platform agent skills (synced into user workspaces):', + ' skills/', 'Key runtime differences must stay in .env, not in the artifact:', ' DATABASE_URL / MYSQL_*', ' H5_PUBLIC_BASE_URL', From 08c5b22d4a0a3b29ed9356ea0b6e719cc488e6f5 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 29 Jun 2026 12:34:34 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(voice):=20=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E9=BB=98=E8=AE=A4=E8=B5=B0=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=20ASR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web Speech 依赖 Google,国内浏览器常无法转文字;支持麦克风时改为录音后走自有 ASR,并补全网络异常时的降级匹配。 Co-authored-by: Cursor --- src/hooks/useVoiceSession.ts | 10 ++++------ src/voice/capabilities.ts | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/hooks/useVoiceSession.ts b/src/hooks/useVoiceSession.ts index a5b1afe..8fae289 100644 --- a/src/hooks/useVoiceSession.ts +++ b/src/hooks/useVoiceSession.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { transcribeOneShot } from '../voice/asrTransport'; import { mapMicError } from '../voice/audioAnalyser'; -import { shouldFallbackToServerAsr, shouldPreferServerAsr } from '../voice/capabilities'; +import { shouldFallbackToServerAsr, shouldUseLiveSpeech } from '../voice/capabilities'; import { MicCapture } from '../voice/micCapture'; import { waitForMicRelease } from '../voice/micSession'; -import { createLiveSpeechRecognition, isSpeechRecognitionSupported } from '../voice/speechRecognition'; +import { createLiveSpeechRecognition } from '../voice/speechRecognition'; import type { VoiceSessionPhase } from '../voice/types'; const MIN_RECORD_MS = 500; @@ -19,9 +19,7 @@ export function useVoiceSession({ const [phase, setPhase] = useState('idle'); const [text, setText] = useState(''); const [analyser, setAnalyser] = useState(null); - const [liveRecognition, setLiveRecognition] = useState( - isSpeechRecognitionSupported() && !shouldPreferServerAsr(), - ); + const [liveRecognition, setLiveRecognition] = useState(shouldUseLiveSpeech()); const committedRef = useRef(''); const interimRef = useRef(''); @@ -155,7 +153,7 @@ export function useVoiceSession({ if (sessionRef.current !== sessionId) return; startedAtRef.current = Date.now(); - const useLiveSpeech = isSpeechRecognitionSupported() && !shouldPreferServerAsr(); + const useLiveSpeech = shouldUseLiveSpeech(); if (useLiveSpeech) { const recognition = createLiveSpeechRecognition({ diff --git a/src/voice/capabilities.ts b/src/voice/capabilities.ts index 97e787d..cbf1955 100644 --- a/src/voice/capabilities.ts +++ b/src/voice/capabilities.ts @@ -2,16 +2,26 @@ import { isWechatBrowser } from '../utils/wechat'; import { MicCapture } from './micCapture'; import { isSpeechRecognitionSupported } from './speechRecognition'; -/** 微信内置浏览器 Web Speech 不可用,直接走服务端 ASR。 */ +/** + * 优先服务端 ASR(录音后识别): + * - 微信内 Web Speech 不可用; + * - 普通浏览器若支持麦克风,也走自有 ASR(Web Speech 依赖 Google,国内常无结果)。 + */ export function shouldPreferServerAsr(): boolean { - return isWechatBrowser(); + if (isWechatBrowser()) return true; + return MicCapture.isSupported(); } -/** Web Speech 出现这些错误时可改用服务端 ASR(录音 + 完成识别)。 */ +/** 仍尝试 Web Speech 时,出现这些错误可降级为服务端 ASR。 */ export function shouldFallbackToServerAsr(errorMessage: string): boolean { if (shouldPreferServerAsr()) return false; if (!MicCapture.isSupported()) return false; - return /network|service-not-allowed|语音识别失败|无法启动语音识别/i.test(errorMessage); + return /network|网络异常|service-not-allowed|语音识别失败|无法启动语音识别/i.test(errorMessage); +} + +/** 是否使用浏览器 Web Speech 实时听写(仅在不走服务端 ASR 且 API 可用时)。 */ +export function shouldUseLiveSpeech(): boolean { + return isSpeechRecognitionSupported() && !shouldPreferServerAsr(); } /** Whether voice input may work in this browser (mic and/or live speech). */ From 18ea4f82fd82cbda6d845d354a93cba636c43c6f Mon Sep 17 00:00:00 2001 From: john Date: Mon, 29 Jun 2026 20:49:15 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(feedback):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8F=8D=E9=A6=88=E6=8F=90=E4=BA=A4=E3=80=81?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=88=97=E8=A1=A8=E4=B8=8E=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持 Bug/需求提交、截图与聊天同款语音输入,全站反馈分页浏览与详情页,并从聊天与空间页提供入口。 Co-authored-by: Cursor --- .runtime/portal/schema.sql | 92 +++ db.mjs | 19 + schema.sql | 17 + server.mjs | 316 ++++++++-- src/App.tsx | 24 + src/api/client.ts | 114 +++- src/components/ChatView.tsx | 63 +- src/components/FeedbackBoardView.tsx | 179 ++++++ src/components/FeedbackDetailView.tsx | 137 ++++ src/components/FeedbackPageHeader.tsx | 30 + src/components/FeedbackSubmitView.tsx | 430 +++++++++++++ src/components/MindSpaceView.tsx | 170 +++-- src/index.css | 872 ++++++++++++++++++++++++++ src/routes/FeedbackRoute.tsx | 28 + src/routes/MindSpaceRoute.tsx | 1 + src/types.ts | 79 ++- src/utils/feedbackLabels.ts | 28 + user-feedback.mjs | 235 +++++++ user-feedback.test.mjs | 162 +++++ 19 files changed, 2846 insertions(+), 150 deletions(-) create mode 100644 src/components/FeedbackBoardView.tsx create mode 100644 src/components/FeedbackDetailView.tsx create mode 100644 src/components/FeedbackPageHeader.tsx create mode 100644 src/components/FeedbackSubmitView.tsx create mode 100644 src/routes/FeedbackRoute.tsx create mode 100644 src/utils/feedbackLabels.ts create mode 100644 user-feedback.mjs create mode 100644 user-feedback.test.mjs diff --git a/.runtime/portal/schema.sql b/.runtime/portal/schema.sql index c02695e..20460a4 100644 --- a/.runtime/portal/schema.sql +++ b/.runtime/portal/schema.sql @@ -223,6 +223,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records ( token_hash CHAR(64) NULL, token_prefix VARCHAR(16) NULL, expires_at BIGINT NULL, + user_confirmed_at BIGINT NULL, published_at BIGINT NOT NULL, offline_at BIGINT NULL, status ENUM('draft', 'online', 'expired', 'offline', 'blocked') NOT NULL DEFAULT 'online', @@ -232,6 +233,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records ( updated_at BIGINT NOT NULL, KEY idx_h5_publish_route (url_slug, status, published_at), KEY idx_h5_publish_page (user_id, page_id, published_at), + KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at), UNIQUE KEY uq_h5_publish_token_hash (token_hash), CONSTRAINT fk_h5_publish_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, CONSTRAINT fk_h5_publish_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE, @@ -239,6 +241,17 @@ CREATE TABLE IF NOT EXISTS h5_publish_records ( CONSTRAINT fk_h5_publish_scan FOREIGN KEY (security_scan_id) REFERENCES h5_security_scans(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS h5_publication_asset_refs ( + publication_id CHAR(36) NOT NULL, + asset_id CHAR(36) NOT NULL, + created_at BIGINT NOT NULL, + PRIMARY KEY (publication_id, asset_id), + KEY idx_asset_id (asset_id), + KEY idx_created_at (created_at), + CONSTRAINT fk_h5_pub_asset_ref_pub FOREIGN KEY (publication_id) REFERENCES h5_publish_records(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_pub_asset_ref_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS h5_publication_events ( id CHAR(36) PRIMARY KEY, publish_id CHAR(36) NOT NULL, @@ -647,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 '', @@ -997,3 +1011,81 @@ CREATE TABLE IF NOT EXISTS h5_blocked_words ( updated_at BIGINT NOT NULL, UNIQUE KEY uk_blocked_word (word) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Shared experience store (etat C): all goosed Worker instances read/write the +-- same learned experience so capability is not siloed per instance. Built on +-- MySQL first with a keyword + recency retrieval; `embedding` is reserved for a +-- later move to PostgreSQL + pgvector without changing the calling contract. +CREATE TABLE IF NOT EXISTS h5_experience ( + id CHAR(36) PRIMARY KEY, + scope VARCHAR(64) NOT NULL DEFAULT 'global', + kind VARCHAR(32) NOT NULL DEFAULT 'lesson', + title VARCHAR(255) NOT NULL, + body MEDIUMTEXT NOT NULL, + tags_json JSON NULL, + source_session_id VARCHAR(128) NULL, + source_user_id CHAR(36) NULL, + use_count BIGINT NOT NULL DEFAULT 0, + embedding JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_experience_scope_updated (scope, updated_at), + FULLTEXT KEY ftx_h5_experience (title, body) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_conversation_messages ( + id CHAR(64) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + agent_session_id VARCHAR(128) NOT NULL, + message_key VARCHAR(128) NOT NULL, + sequence_no INT NOT NULL DEFAULT 0, + role VARCHAR(32) NOT NULL, + text MEDIUMTEXT NOT NULL, + raw_json LONGTEXT NULL, + analyzed_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_conversation_message (agent_session_id, message_key), + KEY idx_h5_conversation_user_created (user_id, created_at), + KEY idx_h5_conversation_user_analyzed (user_id, analyzed_at), + CONSTRAINT fk_h5_conversation_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_conversation_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_memory_items ( + id CHAR(64) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + label ENUM('preference', 'habit', 'interest', 'goal', 'fact', 'experience', 'knowledge') NOT NULL DEFAULT 'fact', + memory_hash CHAR(64) NOT NULL, + memory_text TEXT NOT NULL, + evidence_message_id CHAR(64) NULL, + source_session_id VARCHAR(128) NULL, + confidence DECIMAL(4,3) NOT NULL DEFAULT 0.600, + status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active', + raw_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_user_memory_hash (user_id, memory_hash), + KEY idx_h5_user_memory_user_label (user_id, label, updated_at), + KEY idx_h5_user_memory_session (source_session_id), + CONSTRAINT fk_h5_user_memory_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_user_memory_evidence FOREIGN KEY (evidence_message_id) REFERENCES h5_conversation_messages(id) ON DELETE SET NULL, + CONSTRAINT fk_h5_user_memory_session FOREIGN KEY (source_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_feedback ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + type ENUM('bug', 'feature', 'other') NOT NULL, + title VARCHAR(120) NOT NULL, + description TEXT NOT NULL, + contact VARCHAR(120) NULL, + status ENUM('pending', 'reviewing', 'resolved', 'closed') NOT NULL DEFAULT 'pending', + images_json JSON NULL, + context_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_user_feedback_user_created (user_id, created_at), + KEY idx_h5_user_feedback_status_created (status, created_at), + CONSTRAINT fk_h5_user_feedback_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/db.mjs b/db.mjs index 1f9ff44..c3bda54 100644 --- a/db.mjs +++ b/db.mjs @@ -645,6 +645,25 @@ export async function migrateSchema(pool) { WHERE s.user_id = u.id AND s.status = 'active' ) `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_user_feedback ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + type ENUM('bug', 'feature', 'other') NOT NULL, + title VARCHAR(120) NOT NULL, + description TEXT NOT NULL, + contact VARCHAR(120) NULL, + status ENUM('pending', 'reviewing', 'resolved', 'closed') NOT NULL DEFAULT 'pending', + images_json JSON NULL, + context_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_user_feedback_user_created (user_id, created_at), + KEY idx_h5_user_feedback_status_created (status, created_at), + CONSTRAINT fk_h5_user_feedback_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); } export async function initSchema(pool) { diff --git a/schema.sql b/schema.sql index e408cd4..20460a4 100644 --- a/schema.sql +++ b/schema.sql @@ -1072,3 +1072,20 @@ CREATE TABLE IF NOT EXISTS h5_user_memory_items ( CONSTRAINT fk_h5_user_memory_evidence FOREIGN KEY (evidence_message_id) REFERENCES h5_conversation_messages(id) ON DELETE SET NULL, CONSTRAINT fk_h5_user_memory_session FOREIGN KEY (source_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_user_feedback ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + type ENUM('bug', 'feature', 'other') NOT NULL, + title VARCHAR(120) NOT NULL, + description TEXT NOT NULL, + contact VARCHAR(120) NULL, + status ENUM('pending', 'reviewing', 'resolved', 'closed') NOT NULL DEFAULT 'pending', + images_json JSON NULL, + context_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_user_feedback_user_created (user_id, created_at), + KEY idx_h5_user_feedback_status_created (status, created_at), + CONSTRAINT fk_h5_user_feedback_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server.mjs b/server.mjs index 6fa81a2..4448cc6 100644 --- a/server.mjs +++ b/server.mjs @@ -77,6 +77,8 @@ import { resolveChatSaveAnalysis, resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; +import { syncPublicHtmlAfterFinish } from './mindspace-public-finish-sync.mjs'; +import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; import { injectOgTags } from './mindspace-og-tags.mjs'; import { @@ -96,6 +98,7 @@ import { import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs'; import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs'; import { createScheduleService } from './schedule-service.mjs'; +import { createFeedbackService } from './user-feedback.mjs'; import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs'; import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs'; import { createSessionSnapshotService } from './session-snapshot.mjs'; @@ -202,16 +205,13 @@ const rawUploadBody = express.raw({ type: 'application/octet-stream', limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), }); -const rawUploadBodyImage = express.raw({ - type: 'application/octet-stream', - limit: 2 * 1024 * 1024, -}); - const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db')); let legacyAuth = null; -if (ACCESS_PASSWORD) { +if (ACCESS_PASSWORD && !isDatabaseConfigured()) { legacyAuth = createAuthManager({ password: ACCESS_PASSWORD }); +} else if (ACCESS_PASSWORD && isDatabaseConfigured()) { + console.log('H5_ACCESS_PASSWORD ignored: multi-user database auth is configured'); } let userAuth = null; @@ -241,6 +241,7 @@ let wechatPayClient = null; let wechatOAuthService = null; let wechatMpService = null; let scheduleService = null; +let feedbackService = null; let scheduleReminderWorker = null; let llmProviderService = null; let wordFilterService = null; @@ -258,6 +259,7 @@ async function bootstrapUserAuth() { scheduleService = createScheduleService(pool, { defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', }); + feedbackService = createFeedbackService(pool); mindSpace = createMindSpaceService(pool, { maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10), @@ -605,6 +607,12 @@ async function bootstrapUserAuth() { return true; } catch (err) { console.error('User auth bootstrap failed:', err); + if (isDatabaseConfigured() && process.env.NODE_ENV === 'production') { + console.error( + 'Fatal: database is configured but user auth bootstrap failed; exiting so launchd can retry', + ); + process.exit(1); + } return false; } } @@ -661,6 +669,13 @@ app.get('/auth/status', async (req, res) => { unrestricted: capabilityState.unrestricted, }); } + if (isDatabaseConfigured()) { + return res.status(503).json({ + authenticated: false, + mode: 'unavailable', + message: '用户认证服务不可用,请稍后重试', + }); + } if (legacyAuth) { return res.json({ authenticated: legacyAuth.verify(legacySessionToken(req)), @@ -1059,6 +1074,83 @@ app.get('/auth/usage', async (req, res) => { res.json({ records }); }); +app.post('/auth/feedback', jsonBody, async (req, res) => { + await userAuthReady; + if (!userAuth || !feedbackService) { + return res.status(503).json({ message: '反馈服务未启用' }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + try { + const result = await feedbackService.submit(me.id, { + type: req.body?.type, + title: req.body?.title, + description: req.body?.description, + contact: req.body?.contact, + images: req.body?.images, + context: req.body?.context, + }); + res.status(201).json({ feedback: result }); + } catch (err) { + const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''; + if (code === 'invalid_input') { + return res.status(400).json({ message: err instanceof Error ? err.message : '提交内容无效' }); + } + console.warn('Submit feedback failed:', err instanceof Error ? err.message : err); + return res.status(500).json({ message: '反馈提交失败,请稍后重试' }); + } +}); + +app.get('/auth/feedback', async (req, res) => { + await userAuthReady; + if (!userAuth || !feedbackService) { + return res.status(503).json({ message: '反馈服务未启用' }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 50); + const items = await feedbackService.listForUser(me.id, { limit }); + res.json({ items }); +}); + +app.get('/auth/feedback/board', async (req, res) => { + await userAuthReady; + if (!userAuth || !feedbackService) { + return res.status(503).json({ message: '反馈服务未启用' }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + const page = Math.max(Number(req.query?.page) || 1, 1); + const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 10); + try { + const result = await feedbackService.listAll({ page, limit }); + res.json(result); + } catch (err) { + console.warn('List feedback board failed:', err instanceof Error ? err.message : err); + return res.status(500).json({ message: '反馈列表加载失败' }); + } +}); + +app.get('/auth/feedback/:feedbackId', async (req, res) => { + await userAuthReady; + if (!userAuth || !feedbackService) { + return res.status(503).json({ message: '反馈服务未启用' }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + try { + const item = await feedbackService.getById(req.params.feedbackId, me.id); + res.json({ item, isMine: item.userId === me.id }); + } catch (err) { + const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''; + if (code === 'feedback_not_found') { + return res.status(404).json({ message: err instanceof Error ? err.message : '反馈不存在' }); + } + console.warn('Get feedback detail failed:', err instanceof Error ? err.message : err); + return res.status(500).json({ message: '反馈详情加载失败' }); + } +}); + app.get('/auth/notifications', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { @@ -1627,6 +1719,115 @@ api.get('/status', async (_req, res, next) => { return next(); }); +async function ensureUserMemoryCapability(req, res) { + if (!userAuth) { + res.status(503).json({ message: '未启用用户系统' }); + return null; + } + const userRow = await userAuth.getUserById(req.currentUser.id); + if (!userRow) { + res.status(404).json({ message: '用户不存在' }); + return null; + } + const capabilityState = await userAuth.resolveUserCapabilities(userRow); + if (!capabilityState.unrestricted && !capabilityState.capabilities.memory_store) { + res.status(403).json({ message: '当前账户未开通长期记忆,无法访问该 API' }); + return null; + } + return capabilityState; +} + +async function loadUserVisibleConversation(sessionId) { + const target = await tkmindProxy.resolveTarget(sessionId); + const upstream = await tkmindProxy.apiFetchTo(target, `/sessions/${encodeURIComponent(sessionId)}`, { + method: 'GET', + }); + if (!upstream.ok) { + const message = await upstream.text().catch(() => ''); + throw new Error(message || '读取会话失败'); + } + const session = await upstream.json(); + return (session?.conversation ?? []).filter((message) => message?.metadata?.userVisible); +} + +async function syncUserMemoriesIntoSession(userId, sessionId) { + if (!tkmindProxy || !sessionId) return false; + await tkmindProxy.reconcileSessionPolicyForUser(userId, sessionId); + return true; +} + +api.post('/user-memory/v1/remember-recent', async (req, res) => { + if (!conversationMemoryService?.isEnabled?.()) { + return res.status(503).json({ message: '长期记忆功能未启用' }); + } + if (!tkmindProxy) { + return res.status(503).json({ message: '会话代理尚未就绪' }); + } + const capabilityState = await ensureUserMemoryCapability(req, res); + if (!capabilityState) return; + + const sessionId = String(req.body?.sessionId ?? '').trim(); + if (!sessionId) { + return res.status(400).json({ message: '缺少 sessionId' }); + } + const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + return res.status(403).json({ message: '无权访问该会话' }); + } + + try { + const messages = await loadUserVisibleConversation(sessionId); + const result = await conversationMemoryService.saveAndAnalyze( + sessionId, + req.currentUser.id, + messages, + ); + const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); + const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 }); + return res.json({ + ok: true, + analyzed: result.analyzed ?? 0, + memories: result.memories ?? 0, + totalMemories: memories.length, + syncedToSession, + }); + } catch (err) { + return res.status(500).json({ message: err instanceof Error ? err.message : '保存长期记忆失败' }); + } +}); + +api.post('/user-memory/v1/sync', async (req, res) => { + if (!conversationMemoryService?.isEnabled?.()) { + return res.status(503).json({ message: '长期记忆功能未启用' }); + } + const capabilityState = await ensureUserMemoryCapability(req, res); + if (!capabilityState) return; + + const sessionId = String(req.body?.sessionId ?? '').trim(); + if (!sessionId) { + return res.status(400).json({ message: '缺少 sessionId' }); + } + const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + return res.status(403).json({ message: '无权访问该会话' }); + } + + try { + const result = await conversationMemoryService.analyzeUser(req.currentUser.id); + const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); + const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 }); + return res.json({ + ok: true, + analyzed: result.analyzed ?? 0, + memories: result.memories ?? 0, + totalMemories: memories.length, + syncedToSession, + }); + } catch (err) { + return res.status(500).json({ message: err instanceof Error ? err.message : '刷新长期记忆失败' }); + } +}); + api.get('/mindspace/v1/space', async (req, res) => { if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return; const space = await mindSpace.getSpace(req.currentUser.id); @@ -2103,7 +2304,7 @@ api.post('/mindspace/v1/uploads', async (req, res) => { } }); -api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBodyImage, async (req, res) => { +api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBody, async (req, res) => { if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpaceAssets.writeUploadContent( @@ -2426,25 +2627,23 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) { return { session, message, content }; } -const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'private', 'public']); +const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); -function assertPrivateSaveAllowed(categoryCode, privacyScan, acknowledgedFindingIds) { - if (categoryCode !== 'private') return; - if (!privacyScan.allowed) { - throw Object.assign(new Error('内容含阻断级敏感信息,不能保存到私人区'), { - code: 'security_risk_blocked', - details: { findings: privacyScan.findings }, - }); - } - if (privacyScan.findings.length === 0) return; - const acknowledged = new Set((acknowledgedFindingIds ?? []).map(String)); - const missing = privacyScan.findings.filter((finding) => !acknowledged.has(finding.id)); - if (missing.length > 0) { - throw Object.assign(new Error('保存到私人区前需确认敏感信息提示'), { - code: 'private_ack_required', - details: { findings: missing }, - }); - } +async function syncUserGeneratedPages(userId) { + if (!mindSpacePages || !authPool || !userId) return; + await syncGeneratedPagesFromPublicAssets({ + pool: authPool, + pageService: mindSpacePages, + assetService: mindSpaceAssets, + userId, + publishDir: resolvePublishDir(__dirname, { id: userId }), + syncWorkspaceAssets: + mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED + ? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options) + : null, + }).catch((error) => { + console.warn('[MindSpace] page sync failed:', error?.message ?? error); + }); } async function resolveChatSaveBundle(user, h5Root, input = {}) { @@ -2733,7 +2932,6 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null); } const privacyScan = scanContent(resolvedHtml?.content ?? source.content); - assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids); if (categoryCode !== 'draft') { let buffer; @@ -2847,6 +3045,7 @@ api.post('/mindspace/v1/pages', async (req, res) => { api.get('/mindspace/v1/pages', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { + await syncUserGeneratedPages(req.currentUser.id); const pages = await mindSpacePages.listPages(req.currentUser.id, { status: typeof req.query.status === 'string' ? req.query.status : undefined, }); @@ -3777,14 +3976,42 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } - // After Finish, async-refresh snapshot so next open is a cache hit. - const onAfterFinish = sessionSnapshotService?.isEnabled() - ? (sid, uid) => - sessionSnapshotService.refresh(sid, uid, async (pathname, init) => { - const target = await tkmindProxy.resolveTarget(sid); - return tkmindProxy.apiFetchTo(target, pathname, init); - }) - : null; + // After Finish, refresh the snapshot and persist any newly generated public + // workspace HTML into the asset store before a later restart rebuilds the + // workspace from DB-backed assets only. + const onAfterFinish = async (sid, uid) => { + const apiFetchFn = async (pathname, init) => { + const target = await tkmindProxy.resolveTarget(sid); + return tkmindProxy.apiFetchTo(target, pathname, init); + }; + let messages = null; + if (sessionSnapshotService?.isEnabled()) { + await sessionSnapshotService.refresh(sid, uid, apiFetchFn); + messages = (await sessionSnapshotService.get(sid))?.messages ?? null; + } else { + try { + const upstream = await apiFetchFn(`/sessions/${encodeURIComponent(sid)}`, { method: 'GET' }); + if (upstream.ok) { + const payload = await upstream.json().catch(() => null); + messages = Array.isArray(payload?.conversation) + ? payload.conversation.filter((message) => message?.metadata?.userVisible) + : null; + } + } catch { + messages = null; + } + } + await syncPublicHtmlAfterFinish({ + messages, + currentUser: req.currentUser, + publishDir: resolvePublishDir(__dirname, { id: uid }), + syncWorkspaceAssets: + WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets + ? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options) + : null, + }); + await syncUserGeneratedPages(uid); + }; return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish }); }); @@ -4648,6 +4875,14 @@ async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) { return null; } +function decodePathSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + async function serveUserPublishFile(req, res, next) { const parts = req.path.split('/').filter(Boolean); if (parts.length < 1) { @@ -4668,7 +4903,7 @@ async function serveUserPublishFile(req, res, next) { return; } - const [username, ...rest] = [dirKey, ...parts.slice(1)]; + const [username, ...rest] = [dirKey, ...parts.slice(1).map(decodePathSegment)]; const targetDir = path.join(__dirname, PUBLISH_ROOT_DIR, username); const resolvedRoot = path.resolve(targetDir); const filePath = path.join(targetDir, ...rest); @@ -4817,12 +5052,23 @@ app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => { res.type('text/plain').sendFile(filePath); }); +app.use('/auth', (_req, res) => { + res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' }); +}); +app.use('/admin-api', (_req, res) => { + res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' }); +}); + app.use(express.static(path.join(__dirname, 'dist'), { index: 'index.html' })); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); userAuthReady.then((enabled) => { + if (isDatabaseConfigured() && !enabled && process.env.NODE_ENV === 'production') { + console.error('Refusing to start portal without user auth while database is configured'); + process.exit(1); + } app.listen(PORT, HOST, () => { console.log(`TKMind H5 @ http://${HOST}:${PORT}`); console.log(`Proxy -> ${API_TARGETS.join(', ')}`); diff --git a/src/App.tsx b/src/App.tsx index 81dcbfc..4e65dd8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { MindSpaceView } from './components/MindSpaceView'; import { ChatProvider } from './context/ChatProvider'; import { PREVIEW_USER } from './dev/mindspacePreviewData'; import { MindSpaceRoute } from './routes/MindSpaceRoute'; +import { FeedbackRoutes } from './routes/FeedbackRoute'; import type { CapabilityMap, PortalUser } from './types'; function isMindSpacePreview() { @@ -86,6 +87,7 @@ function AuthenticatedApp({ ? () => { window.location.href = import.meta.env.VITE_ADMIN_APP_URL ?? '/admin-app'; } : undefined } + onOpenFeedback={() => navigate('/feedback')} onLogout={handleLogout} /> ); @@ -102,6 +104,10 @@ function AuthenticatedApp({ path="/space/*" element={} /> + } + /> } /> @@ -117,6 +123,7 @@ export function App() { const [capabilities, setCapabilities] = useState(); const [grantedSkills, setGrantedSkills] = useState(); const [legacyMode, setLegacyMode] = useState(false); + const [authUnavailable, setAuthUnavailable] = useState(null); useEffect(() => { if (mindSpacePreview) return; @@ -128,6 +135,12 @@ export function App() { } }); void checkAuth().then((status) => { + if (status.mode === 'unavailable') { + setAuthUnavailable(status.message ?? '用户认证服务暂时不可用,请稍后刷新'); + setAuthed(false); + return; + } + setAuthUnavailable(null); setLegacyMode(status.mode === 'legacy'); setAuthed(status.authenticated); setUser(status.user ?? null); @@ -159,6 +172,17 @@ export function App() { return
正在验证登录状态…
; } + if (authUnavailable) { + return ( +
+ {authUnavailable} + +
+ ); + } + if (!authed) { return ( (path: string, init?: RequestInit): Promise { if (res.status === 204) return undefined as T; const text = await res.text().catch(() => ''); if (text.trimStart().startsWith('<')) { - throw new ApiError(res.status, '服务器返回了意外的页面,请确认后端服务已启动并已重启'); + throw new ApiError( + res.status, + `接口 ${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`, + ); } try { return JSON.parse(text) as T; @@ -272,7 +284,10 @@ async function apiFetch(path: string, init?: RequestInit): Promise { if (res.status === 204) return undefined as T; const rawText = await res.text().catch(() => ''); if (rawText.trimStart().startsWith('<')) { - throw new ApiError(res.status, '服务器返回了意外的页面,请确认后端服务已启动并已重启'); + throw new ApiError( + res.status, + `接口 ${API}${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`, + ); } try { return JSON.parse(rawText) as T; @@ -310,6 +325,20 @@ export async function rememberProjectContext( }); } +export async function rememberUserMemory(sessionId: string): Promise { + return apiFetch('/user-memory/v1/remember-recent', { + method: 'POST', + body: JSON.stringify({ sessionId }), + }); +} + +export async function syncUserMemory(sessionId: string): Promise { + return apiFetch('/user-memory/v1/sync', { + method: 'POST', + body: JSON.stringify({ sessionId }), + }); +} + export type WechatAuthConfig = { enabled: boolean; inWechat?: boolean; @@ -380,8 +409,11 @@ export async function getWechatJsSdkSignature(url: string): Promise { try { const response = await fetch('/auth/status'); - if (!response.ok) return { authenticated: false }; const status = (await response.json()) as AuthStatus; + if (!response.ok) { + if (status.mode === 'unavailable') return status; + return { authenticated: false }; + } if (status.authenticated) resetUnauthorizedGuard(); if (status.authenticated && status.mode === 'user' && !status.capabilities) { try { @@ -416,6 +448,53 @@ export async function getMyUsage(): Promise { return result.records ?? []; } +export async function submitFeedback(input: { + type: FeedbackSubmissionType; + title: string; + description: string; + contact?: string; + images?: FeedbackImageInput[]; + context?: FeedbackContextInput; +}): Promise { + const result = await portalFetch<{ feedback: FeedbackSubmission }>('/auth/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: input.type, + title: input.title, + description: input.description, + contact: input.contact, + images: input.images, + context: input.context, + }), + }); + return result.feedback; +} + +export async function getMyFeedback(limit = 20): Promise { + const result = await portalFetch<{ items: FeedbackSubmission[] }>( + `/auth/feedback?limit=${encodeURIComponent(String(limit))}`, + ); + return result.items ?? []; +} + +export async function listFeedbackBoard(page = 1, limit = 10): Promise { + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + }); + return portalFetch(`/auth/feedback/board?${params.toString()}`); +} + +export async function getFeedbackDetail(feedbackId: string): Promise<{ + item: FeedbackSubmission; + isMine: boolean; +}> { + return portalFetch<{ item: FeedbackSubmission; isMine: boolean }>( + `/auth/feedback/${encodeURIComponent(feedbackId)}`, + ); +} + export async function getMyBillingLedger(limit = 30): Promise { const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : ''; const result = await portalFetch<{ entries: LedgerEntry[] }>(`/auth/billing/ledger${query}`); @@ -521,11 +600,13 @@ export async function listMindSpaceAssets( export async function uploadMindSpaceAsset( categoryId: string, file: File, + options: { maxImageBytes?: number } = {}, ): Promise { - if (file.type.startsWith('image/') && file.size > CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES) { + const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES; + if (file.type.startsWith('image/') && file.size > maxImageBytes) { throw new ApiError( 413, - `图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB,请先压缩到 ${CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES / 1024 / 1024}MB 以下再上传。`, + `图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB,超过 ${maxImageBytes / 1024 / 1024}MB 上传上限。`, ); } @@ -2003,9 +2084,18 @@ export async function applyLocalLlmFallback(sessionId: string): Promise<{ }); } -export async function listSessions(): Promise { - const result = await apiFetch('/sessions'); - return result.sessions ?? []; +export async function listSessions(options?: { + limit?: number; + offset?: number; + query?: string; +}): Promise<{ items: SessionSummary[]; page: SessionListPage }> { + const params = new URLSearchParams(); + if (options?.limit != null) params.set('limit', String(options.limit)); + if (options?.offset != null) params.set('offset', String(options.offset)); + if (options?.query?.trim()) params.set('query', options.query.trim()); + const qs = params.size ? `?${params.toString()}` : ''; + const result = await apiFetch(`/sessions${qs}`); + return { items: result.sessions ?? [], page: result.page ?? {} }; } function sessionPath(sessionId: string, suffix = '') { @@ -2023,19 +2113,23 @@ export async function deleteChatSession(sessionId: string): Promise { export async function loadSessionDetail( sessionId: string, hints?: { messageCount?: number; updatedAt?: string }, + history?: { before?: number; limit?: number }, ): Promise<{ session: Session; messages: Message[]; + page: SessionConversationPage; }> { const params = new URLSearchParams(); if (hints?.messageCount != null) params.set('hint_mc', String(hints.messageCount)); if (hints?.updatedAt) params.set('hint_ua', hints.updatedAt); + if (history?.before != null) params.set('history_before', String(history.before)); + if (history?.limit != null) params.set('history_limit', String(history.limit)); const qs = params.size ? `?${params.toString()}` : ''; const detail = await apiFetch(`${sessionPath(sessionId)}${qs}`); const messages = normalizeConversationMessages( (detail.conversation ?? []).filter((m) => m.metadata?.userVisible), ); - return { session: detail, messages }; + return { session: detail, messages, page: detail.conversation_page ?? {} }; } export async function sendReply( diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index c5e120f..eb020a3 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -4,7 +4,6 @@ import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat'; import type { CapabilityMap, PortalUser } from '../types'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { getSessionDisplayName } from '../utils/sessions'; -import { openAvatarPicker } from '../utils/userAvatar'; import { BalanceRing } from './BalanceRing'; import { HistorySidebar } from './HistorySidebar'; import { TKMindAvatar } from './TKMindAvatar'; @@ -23,6 +22,7 @@ export function ChatView({ onOpenSpace, onOpenPage, onOpenAdmin, + onOpenFeedback, }: { user?: PortalUser | null; capabilities?: CapabilityMap; @@ -32,25 +32,32 @@ export function ChatView({ onOpenSpace?: (target?: { categoryCode?: MindSpaceSaveCategory; pageId?: string }) => void; onOpenPage?: (pageId: string) => void; onOpenAdmin?: () => void; + onOpenFeedback?: () => void; }) { const { session, sessions, sessionsLoading, + sessionsLoadingMore, + sessionsHasMore, + sessionSearchQuery, messages, + messageHistoryLoadingMore, + messageHistoryHasMore, + messageHistoryTotal, chatState, error, notice, pendingTool, - memoryLoading, submit, stop, approveTool, newSession, - rememberCurrentContext, - refreshProjectMemory, switchSession, deleteSession, + loadMoreSessions, + loadOlderMessages, + setSessionSearchQuery, retryConnect, dismissNotice, onSidebarOpen, @@ -71,7 +78,6 @@ export function ChatView({ } }, [sidebarOpen, onSidebarOpen]); - const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting'; const showHomeWelcome = messages.length === 0; const handleSelectSession = (sessionId: string) => { @@ -96,18 +102,9 @@ export function ChatView({ label: '新会话', onClick: () => void handleNewSession(), }, - { - id: 'remember', - label: '记住本轮', - onClick: () => void rememberCurrentContext(), - disabled: busy || memoryLoading || messages.length === 0, - }, - { - id: 'refresh-memory', - label: memoryLoading ? '同步中…' : '刷新记忆', - onClick: () => void refreshProjectMemory(), - disabled: busy || memoryLoading, - }, + ...(onOpenFeedback + ? [{ id: 'feedback', label: '反馈与建议', onClick: () => onOpenFeedback() }] + : []), ...(onOpenAdmin ? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }] : []), @@ -123,10 +120,15 @@ export function ChatView({ sessions={sessions} activeSessionId={session?.id} loading={sessionsLoading} + loadingMore={sessionsLoadingMore} + hasMore={sessionsHasMore} + searchQuery={sessionSearchQuery} onClose={() => setSidebarOpen(false)} onSelect={handleSelectSession} onNew={handleNewSession} onDelete={(sessionId) => void deleteSession(sessionId)} + onLoadMore={() => void loadMoreSessions()} + onSearchChange={setSessionSearchQuery} />
@@ -168,24 +170,11 @@ export function ChatView({ 我的空间 )} - - + {onOpenFeedback && ( + + )} @@ -292,6 +281,9 @@ export function ChatView({ variant="full" user={user} messages={messages} + historyLoadingMore={messageHistoryLoadingMore} + historyHasMore={messageHistoryHasMore} + historyTotal={messageHistoryTotal} chatState={chatState} pendingTool={pendingTool} session={session} @@ -301,6 +293,7 @@ export function ChatView({ void submit(text, undefined, imageUrls, previewImageUrls) } onUploadImage={uploadChatImage} + onLoadOlderMessages={loadOlderMessages} onStop={stop} onApproveTool={approveTool} onPageSaved={(result) => { diff --git a/src/components/FeedbackBoardView.tsx b/src/components/FeedbackBoardView.tsx new file mode 100644 index 0000000..6bd1c7b --- /dev/null +++ b/src/components/FeedbackBoardView.tsx @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { listFeedbackBoard } from '../api/client'; +import type { FeedbackSubmission, PortalUser } from '../types'; +import { + FEEDBACK_STATUS_LABELS, + FEEDBACK_TYPE_LABELS, + formatFeedbackTime, +} from '../utils/feedbackLabels'; +import { FeedbackPageHeader } from './FeedbackPageHeader'; + +const PAGE_SIZE = 10; + +function FeedbackBoardItem({ + item, + currentUserId, + onOpen, +}: { + item: FeedbackSubmission; + currentUserId?: string; + onOpen: (id: string) => void; +}) { + const isMine = item.userId === currentUserId; + return ( +
  • + +
  • + ); +} + +export function FeedbackBoardView({ + user, + onLogout, +}: { + user?: PortalUser | null; + onLogout?: () => void; +}) { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const page = Math.max(Number(searchParams.get('page')) || 1, 1); + + const [items, setItems] = useState([]); + const [totalPages, setTotalPages] = useState(0); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadPage = useCallback(async (targetPage: number) => { + setLoading(true); + setError(null); + try { + const result = await listFeedbackBoard(targetPage, PAGE_SIZE); + setItems(result.items); + setTotalPages(result.totalPages); + setTotal(result.total); + if (result.totalPages > 0 && targetPage > result.totalPages) { + setSearchParams({ page: String(result.totalPages) }, { replace: true }); + } + } catch (err) { + setError(err instanceof Error ? err.message : '反馈列表加载失败'); + setItems([]); + } finally { + setLoading(false); + } + }, [setSearchParams]); + + useEffect(() => { + void loadPage(page); + }, [loadPage, page]); + + const goToPage = (nextPage: number) => { + if (nextPage < 1 || (totalPages > 0 && nextPage > totalPages) || nextPage === page) return; + setSearchParams({ page: String(nextPage) }); + }; + + return ( +
    + navigate('/feedback')} + onLogout={onLogout} + /> + +
    +
    +
    +
    +

    Bug List

    +

    全部用户反馈

    +

    + 共 {total} 条 · 每页 {PAGE_SIZE} 条 +

    +
    + +
    + + {!loading && total > 0 ? ( +

    第 {page} / {totalPages || 1} 页

    + ) : null} + +
    + {loading ?

    加载中…

    : null} + {error ?

    {error}

    : null} + + {!loading && !error && items.length === 0 ? ( +

    还没有任何用户反馈。

    + ) : null} + + {!loading && items.length > 0 ? ( +
      + {items.map((item) => ( + navigate(`/feedback/${id}`)} + /> + ))} +
    + ) : null} +
    + + {totalPages > 1 ? ( + + ) : null} +
    +
    +
    + ); +} diff --git a/src/components/FeedbackDetailView.tsx b/src/components/FeedbackDetailView.tsx new file mode 100644 index 0000000..83a56da --- /dev/null +++ b/src/components/FeedbackDetailView.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { getFeedbackDetail } from '../api/client'; +import type { FeedbackSubmission } from '../types'; +import { + FEEDBACK_STATUS_LABELS, + FEEDBACK_TYPE_LABELS, + feedbackImageDataUrl, + formatFeedbackTime, +} from '../utils/feedbackLabels'; +import { FeedbackPageHeader } from './FeedbackPageHeader'; + +export function FeedbackDetailView({ + onLogout, +}: { + onLogout?: () => void; +}) { + const navigate = useNavigate(); + const { feedbackId } = useParams<{ feedbackId: string }>(); + const [item, setItem] = useState(null); + const [isMine, setIsMine] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!feedbackId) { + setError('反馈不存在'); + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + void getFeedbackDetail(feedbackId) + .then((result) => { + if (cancelled) return; + setItem(result.item); + setIsMine(result.isMine); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : '反馈详情加载失败'); + setItem(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [feedbackId]); + + return ( +
    + navigate('/feedback/list')} + onLogout={onLogout} + /> + +
    +
    + {loading ?

    加载中…

    : null} + {error ?

    {error}

    : null} + + {!loading && !error && item ? ( + <> +
    +
    + + {FEEDBACK_TYPE_LABELS[item.type]} + + + {FEEDBACK_STATUS_LABELS[item.status]} + + {isMine ? 我的 : null} +
    +

    {item.title}

    +

    + 提交者:{item.submitterDisplayName ?? '用户'} + + {formatFeedbackTime(item.createdAt)} + + 编号 {item.id.slice(0, 8)} +

    +
    + +
    +

    详细描述

    +

    {item.description || '(无描述)'}

    +
    + + {isMine && item.contact ? ( +
    +

    联系方式

    +

    {item.contact}

    +
    + ) : null} + + {item.context?.pagePath ? ( +
    +

    来源页面

    +

    + {item.context.pagePath} +

    +
    + ) : null} + + {(item.images?.length ?? 0) > 0 ? ( +
    + ) : null} + + ) : null} +
    +
    +
    + ); +} diff --git a/src/components/FeedbackPageHeader.tsx b/src/components/FeedbackPageHeader.tsx new file mode 100644 index 0000000..20cf496 --- /dev/null +++ b/src/components/FeedbackPageHeader.tsx @@ -0,0 +1,30 @@ +import { TKMindAvatar } from './TKMindAvatar'; + +export function FeedbackPageHeader({ + title, + onBack, + onLogout, +}: { + title: string; + onBack: () => void; + onLogout?: () => void; +}) { + return ( +
    + +
    + + {title} +
    + {onLogout ? ( + + ) : ( +
    + ); +} diff --git a/src/components/FeedbackSubmitView.tsx b/src/components/FeedbackSubmitView.tsx new file mode 100644 index 0000000..2b5cb7c --- /dev/null +++ b/src/components/FeedbackSubmitView.tsx @@ -0,0 +1,430 @@ +import { useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { submitFeedback } from '../api/client'; +import type { FeedbackImageInput, FeedbackSubmissionType, PortalUser } from '../types'; +import { + CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, + CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES, + CHAT_IMAGE_MAX_SIDE, + compressImageForUpload, +} from '../utils/imageUpload'; +import { FeedbackPageHeader } from './FeedbackPageHeader'; +import { VoiceInputButton } from './VoiceInputButton'; + +const TYPE_OPTIONS: Array<{ value: FeedbackSubmissionType; label: string; hint: string }> = [ + { value: 'bug', label: 'Bug 报告', hint: '功能异常、报错、无法完成操作' }, + { value: 'feature', label: '功能建议', hint: '新能力、体验优化、流程改进' }, + { value: 'other', label: '其他反馈', hint: '账号、计费、文档等问题' }, +]; + +const MAX_IMAGES = 5; + +type PendingImage = { + id: string; + previewUrl: string; + file: File; +}; + +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : ''; + const comma = result.indexOf(','); + resolve(comma >= 0 ? result.slice(comma + 1) : result); + }; + reader.onerror = () => reject(new Error('图片读取失败')); + reader.readAsDataURL(file); + }); +} + +function buildFeedbackContext(sessionId?: string | null) { + return { + pageUrl: window.location.href, + pagePath: `${window.location.pathname}${window.location.search}`, + userAgent: navigator.userAgent, + viewport: `${window.innerWidth}x${window.innerHeight}`, + appVersion: import.meta.env.VITE_APP_VERSION ?? undefined, + sessionId: sessionId ?? undefined, + }; +} + +export function FeedbackSubmitView({ + user, + sessionId, + onLogout, +}: { + user?: PortalUser | null; + sessionId?: string | null; + onLogout?: () => void; +}) { + const navigate = useNavigate(); + const fileInputRef = useRef(null); + const descriptionRef = useRef(null); + const descriptionTextRef = useRef(''); + const voiceBaseRef = useRef(''); + const suppressVoiceUpdateRef = useRef(false); + + const [type, setType] = useState('bug'); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [contact, setContact] = useState(user?.email ?? ''); + const [images, setImages] = useState([]); + const [imageError, setImageError] = useState(null); + const [voiceNotice, setVoiceNotice] = useState(null); + const [voiceRecording, setVoiceRecording] = useState(false); + const [voiceStopSignal, setVoiceStopSignal] = useState(0); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [submittedId, setSubmittedId] = useState(null); + const [uploadingImages, setUploadingImages] = useState(false); + + descriptionTextRef.current = description; + + const selectedType = TYPE_OPTIONS.find((item) => item.value === type) ?? TYPE_OPTIONS[0]; + const voiceDisabled = submitting || uploadingImages; + + const mergeVoiceText = (spoken: string) => { + const base = voiceBaseRef.current.trimEnd(); + const chunk = spoken.trim(); + if (!chunk) return base; + return base ? `${base} ${chunk}` : chunk; + }; + + const handleVoiceStart = () => { + suppressVoiceUpdateRef.current = false; + voiceBaseRef.current = descriptionTextRef.current; + setVoiceNotice(null); + }; + + const handleVoiceLiveTranscript = (text: string) => { + if (suppressVoiceUpdateRef.current) return; + setDescription(mergeVoiceText(text)); + }; + + const handleVoiceTranscript = (text: string) => { + if (suppressVoiceUpdateRef.current) return; + setDescription(mergeVoiceText(text)); + setVoiceNotice('已识别,可编辑后提交'); + }; + + const handleVoiceComplete = () => { + setVoiceNotice('已识别,可编辑后提交'); + }; + + const stopVoiceInput = () => { + suppressVoiceUpdateRef.current = true; + if (voiceRecording) { + setVoiceStopSignal((value) => value + 1); + } + setVoiceRecording(false); + }; + + const handlePickImages = async (files: FileList | null) => { + if (!files?.length) return; + setImageError(null); + + const remaining = MAX_IMAGES - images.length; + if (remaining <= 0) { + setImageError(`最多上传 ${MAX_IMAGES} 张图片`); + return; + } + + const selected = Array.from(files).slice(0, remaining); + setUploadingImages(true); + try { + const nextItems: PendingImage[] = []; + for (const file of selected) { + if (!file.type.startsWith('image/')) { + throw new Error('只支持图片文件'); + } + const compressed = await compressImageForUpload(file, { + maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, + maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES, + maxDimension: CHAT_IMAGE_MAX_SIDE, + }); + nextItems.push({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + previewUrl: URL.createObjectURL(compressed), + file: compressed, + }); + } + setImages((prev) => [...prev, ...nextItems]); + } catch (err) { + setImageError(err instanceof Error ? err.message : '图片处理失败'); + } finally { + setUploadingImages(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const removeImage = (id: string) => { + setImages((prev) => { + const target = prev.find((item) => item.id === id); + if (target) URL.revokeObjectURL(target.previewUrl); + return prev.filter((item) => item.id !== id); + }); + }; + + const resetForm = () => { + stopVoiceInput(); + setSubmittedId(null); + setTitle(''); + setDescription(''); + voiceBaseRef.current = ''; + suppressVoiceUpdateRef.current = false; + setVoiceNotice(null); + setImages([]); + setType('bug'); + setError(null); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + stopVoiceInput(); + + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle) { + suppressVoiceUpdateRef.current = false; + setError('请填写标题'); + return; + } + if (!trimmedDescription) { + suppressVoiceUpdateRef.current = false; + setError('请填写详细描述,或使用语音口述'); + descriptionRef.current?.focus(); + return; + } + + setSubmitting(true); + try { + const imagePayload: FeedbackImageInput[] = []; + for (const item of images) { + imagePayload.push({ + filename: item.file.name, + mimeType: item.file.type || 'image/jpeg', + dataBase64: await fileToBase64(item.file), + }); + } + + const result = await submitFeedback({ + type, + title: trimmedTitle, + description: trimmedDescription, + contact: contact.trim() || undefined, + images: imagePayload, + context: buildFeedbackContext(sessionId), + }); + setSubmittedId(result.id); + suppressVoiceUpdateRef.current = false; + setVoiceNotice(null); + } catch (err) { + suppressVoiceUpdateRef.current = false; + setError(err instanceof Error ? err.message : '提交失败,请稍后重试'); + } finally { + setSubmitting(false); + } + }; + + return ( +
    + navigate(-1)} + onLogout={onLogout} + /> + +
    +
    + {submittedId ? ( +
    +

    提交成功

    +

    感谢你的反馈

    +

    + 我们已收到你的{selectedType.label},编号 {submittedId.slice(0, 8)}。 + 你可以在「用户反馈」中查看进度。 +

    +
    + + + +
    +
    + ) : ( +
    void handleSubmit(event)}> +
    +

    帮助我们改进 TKMind

    +
    +

    提交 Bug 或需求

    + +
    +

    + 你可以用文字描述、上传截图,或点击麦克风口述问题。我们会自动附带当前页面与设备信息,便于定位问题。 +

    +
    + +
    + 反馈类型 +
    + {TYPE_OPTIONS.map((option) => ( + + ))} +
    +
    + + + +
    + + {voiceNotice ? ( +
    + {voiceNotice} +
    + ) : null} +
    +