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 ? ( +
    +

    截图

    +
    + {item.images?.map((image, index) => ( + + {`${item.title} + + ))} +
    +
    + ) : 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} +
    +