From af0acff2aa8eb6722976255bfba461fb5a65461e Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 5 Apr 2026 07:57:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20QA=20=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E7=9A=84=206=20=E9=A1=B9=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-C1: SecureStorage 解密失败上限 — 添加 per-key 失败计数器, 超过 2 次自动清除过期加密数据,阻断无限重试循环 P0-C2: Bootstrap 空指针防护 — connectionStore 中 relayModels[0]?.id 添加 null guard,抛出用户友好错误 P1-H1: 侧边栏对话列表去重 — ConversationList 添加按 ID 去重逻辑, 保留最新版本后按 updatedAt 排序 P1-H2: 搜索框过滤生效 — Sidebar 传递 searchQuery 给 ConversationList, 支持按标题和消息内容过滤 P1-H3: 模型选择器 fallback — 当 SaaS 和 config 均无模型时, 提供 6 个默认模型(GLM/GPT/DeepSeek/Qwen/Claude) P1-H4: 详情面板错误友好化 — RightPanel 中 JS 错误替换为 '连接状态获取失败,请重新连接' Co-Authored-By: Claude Opus 4.6 --- desktop/src/components/ChatArea.tsx | 13 ++++++++- desktop/src/components/ConversationList.tsx | 31 +++++++++++++++++++-- desktop/src/components/RightPanel.tsx | 6 +++- desktop/src/components/Sidebar.tsx | 2 +- desktop/src/lib/secure-storage.ts | 29 ++++++++++++++++--- desktop/src/store/connectionStore.ts | 11 ++++++-- 6 files changed, 81 insertions(+), 11 deletions(-) diff --git a/desktop/src/components/ChatArea.tsx b/desktop/src/components/ChatArea.tsx index 152d06a..e62cd91 100644 --- a/desktop/src/components/ChatArea.tsx +++ b/desktop/src/components/ChatArea.tsx @@ -79,7 +79,18 @@ export function ChatArea() { provider: m.provider_id, })); } - return configModels; + if (configModels.length > 0) { + return configModels; + } + // Fallback: provide common models when no backend is connected + return [ + { id: 'glm-4-flash', name: 'GLM-4 Flash', provider: 'zhipu' }, + { id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' }, + { id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'openai' }, + { id: 'deepseek-chat', name: 'DeepSeek V3', provider: 'deepseek' }, + { id: 'qwen-max', name: 'Qwen Max', provider: 'qwen' }, + { id: 'claude-3-5-sonnet', name: 'Claude 3.5 Sonnet', provider: 'anthropic' }, + ]; }, [isLoggedIn, saasModels, configModels]); const [input, setInput] = useState(''); diff --git a/desktop/src/components/ConversationList.tsx b/desktop/src/components/ConversationList.tsx index b6994ac..b958ac6 100644 --- a/desktop/src/components/ConversationList.tsx +++ b/desktop/src/components/ConversationList.tsx @@ -171,7 +171,7 @@ function ConversationItem({ ); } -export function ConversationList() { +export function ConversationList({ searchQuery = '' }: { searchQuery?: string }) { const conversations = useConversationStore((s) => s.conversations); const currentConversationId = useConversationStore((s) => s.currentConversationId); const { switchConversation, deleteConversation } = useChatStore(); @@ -192,13 +192,40 @@ export function ConversationList() { exportConversation(conv.title, conv.messages); }; + // Deduplicate by id (keep most recent) then filter by search query + const uniqueConversations = (() => { + const seen = new Map(); + for (const conv of conversations) { + const existing = seen.get(conv.id); + if (!existing || conv.updatedAt > existing.updatedAt) { + seen.set(conv.id, conv); + } + } + return Array.from(seen.values()).sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + })(); + + const filtered = searchQuery.trim() + ? uniqueConversations.filter((c) => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.messages.some((m) => m.content.toLowerCase().includes(searchQuery.toLowerCase())) + ) + : uniqueConversations; + if (conversations.length === 0) { return ; } + if (filtered.length === 0 && searchQuery.trim()) { + return ( +
+ 未找到匹配的对话 +
+ ); + } + return (
- {conversations.map((conv) => ( + {filtered.map((conv) => ( )} {error && ( -

{error}

+

+ {error.includes('Cannot read properties') || error.includes('TypeError') + ? '连接状态获取失败,请重新连接' + : error} +

)} diff --git a/desktop/src/components/Sidebar.tsx b/desktop/src/components/Sidebar.tsx index da78446..09427e2 100644 --- a/desktop/src/components/Sidebar.tsx +++ b/desktop/src/components/Sidebar.tsx @@ -123,7 +123,7 @@ export function Sidebar({ )}
- + )} {activeTab === 'clones' && } diff --git a/desktop/src/lib/secure-storage.ts b/desktop/src/lib/secure-storage.ts index 25f219a..e5e586b 100644 --- a/desktop/src/lib/secure-storage.ts +++ b/desktop/src/lib/secure-storage.ts @@ -216,8 +216,18 @@ async function writeEncryptedLocalStorage(key: string, value: string): Promise(); +const MAX_DECRYPTION_RETRIES = 2; + async function readEncryptedLocalStorage(key: string): Promise { try { + // If this key has already failed too many times, skip immediately + const failCount = decryptionFailures.get(key) ?? 0; + if (failCount >= MAX_DECRYPTION_RETRIES) { + return null; + } + const encryptedKey = ENCRYPTED_PREFIX + key; const encryptedRaw = localStorage.getItem(encryptedKey); @@ -230,12 +240,15 @@ async function readEncryptedLocalStorage(key: string): Promise { const parsed = JSON.parse(encryptedRaw); const salt = base64ToArray(parsed.salt); const cryptoKey = await deriveKey(masterKeyRaw, salt); - return await decrypt( + const result = await decrypt( { iv: parsed.iv, data: parsed.data }, cryptoKey, ); + decryptionFailures.delete(key); + return result; } catch (error) { - console.error('[SecureStorage] v2 decryption failed:', error); + console.warn('[SecureStorage] v2 decryption failed for key:', key); + decryptionFailures.set(key, failCount + 1); // Fall through to try v1 } } @@ -245,9 +258,17 @@ async function readEncryptedLocalStorage(key: string): Promise { try { const cryptoKey = await deriveKey(masterKeyRaw); // uses legacy static salt const encrypted = JSON.parse(encryptedRaw); - return await decrypt(encrypted, cryptoKey); + const result = await decrypt(encrypted, cryptoKey); + decryptionFailures.delete(key); + return result; } catch (error) { - console.error('[SecureStorage] v1 decryption failed:', error); + console.warn('[SecureStorage] v1 decryption failed for key:', key); + decryptionFailures.set(key, failCount + 1); + // Both v2 and v1 failed — clear stale encrypted data to stop retrying + if (failCount + 1 >= MAX_DECRYPTION_RETRIES) { + localStorage.removeItem(encryptedKey); + console.warn('[SecureStorage] Cleared stale encrypted data for key:', key); + } } } } diff --git a/desktop/src/store/connectionStore.ts b/desktop/src/store/connectionStore.ts index 08fa01a..97bef3f 100644 --- a/desktop/src/store/connectionStore.ts +++ b/desktop/src/store/connectionStore.ts @@ -503,7 +503,11 @@ export const useConnectionStore = create((set, get) => { } catch { // conversationStore 可能尚未初始化 } - const modelToUse = preferredModel || fallbackModel.id; + const fallbackId = fallbackModel?.id; + if (!fallbackId) { + throw new Error('可用模型数据格式异常,请刷新页面重试'); + } + const modelToUse = preferredModel || fallbackId; kernelClient.setConfig({ provider: 'custom', @@ -538,7 +542,10 @@ export const useConnectionStore = create((set, get) => { } else { // Non-Tauri (browser) — use SaaS relay gateway client for agent listing + chat const { createSaaSRelayGatewayClient } = await import('../lib/saas-relay-client'); - const fallbackModelId = relayModels[0].id; + const fallbackModelId = relayModels[0]?.id; + if (!fallbackModelId) { + throw new Error('可用模型数据格式异常,请刷新页面重试'); + } const relayClient = createSaaSRelayGatewayClient(session.saasUrl, () => { // 每次调用时读取 conversationStore 的 currentModel,fallback 到第一个可用模型 try {