fix(desktop): QA 驱动的 6 项缺陷修复
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -79,7 +79,18 @@ export function ChatArea() {
|
|||||||
provider: m.provider_id,
|
provider: m.provider_id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (configModels.length > 0) {
|
||||||
return configModels;
|
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]);
|
}, [isLoggedIn, saasModels, configModels]);
|
||||||
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ function ConversationItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationList() {
|
export function ConversationList({ searchQuery = '' }: { searchQuery?: string }) {
|
||||||
const conversations = useConversationStore((s) => s.conversations);
|
const conversations = useConversationStore((s) => s.conversations);
|
||||||
const currentConversationId = useConversationStore((s) => s.currentConversationId);
|
const currentConversationId = useConversationStore((s) => s.currentConversationId);
|
||||||
const { switchConversation, deleteConversation } = useChatStore();
|
const { switchConversation, deleteConversation } = useChatStore();
|
||||||
@@ -192,13 +192,40 @@ export function ConversationList() {
|
|||||||
exportConversation(conv.title, conv.messages);
|
exportConversation(conv.title, conv.messages);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Deduplicate by id (keep most recent) then filter by search query
|
||||||
|
const uniqueConversations = (() => {
|
||||||
|
const seen = new Map<string, typeof conversations[number]>();
|
||||||
|
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) {
|
if (conversations.length === 0) {
|
||||||
return <EmptyConversations />;
|
return <EmptyConversations />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0 && searchQuery.trim()) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-sm text-gray-400 py-4">
|
||||||
|
未找到匹配的对话
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5 py-1">
|
<div className="flex flex-col gap-0.5 py-1">
|
||||||
{conversations.map((conv) => (
|
{filtered.map((conv) => (
|
||||||
<ConversationItem
|
<ConversationItem
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
id={conv.id}
|
id={conv.id}
|
||||||
|
|||||||
@@ -707,7 +707,11 @@ export function RightPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-2 text-xs text-red-500 truncate" title={error}>{error}</p>
|
<p className="mt-2 text-xs text-red-500 truncate" title={error}>
|
||||||
|
{error.includes('Cannot read properties') || error.includes('TypeError')
|
||||||
|
? '连接状态获取失败,请重新连接'
|
||||||
|
: error}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ConversationList />
|
<ConversationList searchQuery={searchQuery} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'clones' && <CloneManager />}
|
{activeTab === 'clones' && <CloneManager />}
|
||||||
|
|||||||
@@ -216,8 +216,18 @@ async function writeEncryptedLocalStorage(key: string, value: string): Promise<v
|
|||||||
* Read and decrypt data from localStorage
|
* Read and decrypt data from localStorage
|
||||||
* Supports v2 (random salt), v1 (static salt), and legacy unencrypted formats
|
* Supports v2 (random salt), v1 (static salt), and legacy unencrypted formats
|
||||||
*/
|
*/
|
||||||
|
// Track per-key decryption failures to break infinite retry loops
|
||||||
|
const decryptionFailures = new Map<string, number>();
|
||||||
|
const MAX_DECRYPTION_RETRIES = 2;
|
||||||
|
|
||||||
async function readEncryptedLocalStorage(key: string): Promise<string | null> {
|
async function readEncryptedLocalStorage(key: string): Promise<string | null> {
|
||||||
try {
|
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 encryptedKey = ENCRYPTED_PREFIX + key;
|
||||||
const encryptedRaw = localStorage.getItem(encryptedKey);
|
const encryptedRaw = localStorage.getItem(encryptedKey);
|
||||||
|
|
||||||
@@ -230,12 +240,15 @@ async function readEncryptedLocalStorage(key: string): Promise<string | null> {
|
|||||||
const parsed = JSON.parse(encryptedRaw);
|
const parsed = JSON.parse(encryptedRaw);
|
||||||
const salt = base64ToArray(parsed.salt);
|
const salt = base64ToArray(parsed.salt);
|
||||||
const cryptoKey = await deriveKey(masterKeyRaw, salt);
|
const cryptoKey = await deriveKey(masterKeyRaw, salt);
|
||||||
return await decrypt(
|
const result = await decrypt(
|
||||||
{ iv: parsed.iv, data: parsed.data },
|
{ iv: parsed.iv, data: parsed.data },
|
||||||
cryptoKey,
|
cryptoKey,
|
||||||
);
|
);
|
||||||
|
decryptionFailures.delete(key);
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} 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
|
// Fall through to try v1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,9 +258,17 @@ async function readEncryptedLocalStorage(key: string): Promise<string | null> {
|
|||||||
try {
|
try {
|
||||||
const cryptoKey = await deriveKey(masterKeyRaw); // uses legacy static salt
|
const cryptoKey = await deriveKey(masterKeyRaw); // uses legacy static salt
|
||||||
const encrypted = JSON.parse(encryptedRaw);
|
const encrypted = JSON.parse(encryptedRaw);
|
||||||
return await decrypt(encrypted, cryptoKey);
|
const result = await decrypt(encrypted, cryptoKey);
|
||||||
|
decryptionFailures.delete(key);
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -503,7 +503,11 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
|||||||
} catch {
|
} catch {
|
||||||
// conversationStore 可能尚未初始化
|
// conversationStore 可能尚未初始化
|
||||||
}
|
}
|
||||||
const modelToUse = preferredModel || fallbackModel.id;
|
const fallbackId = fallbackModel?.id;
|
||||||
|
if (!fallbackId) {
|
||||||
|
throw new Error('可用模型数据格式异常,请刷新页面重试');
|
||||||
|
}
|
||||||
|
const modelToUse = preferredModel || fallbackId;
|
||||||
|
|
||||||
kernelClient.setConfig({
|
kernelClient.setConfig({
|
||||||
provider: 'custom',
|
provider: 'custom',
|
||||||
@@ -538,7 +542,10 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
|||||||
} else {
|
} else {
|
||||||
// Non-Tauri (browser) — use SaaS relay gateway client for agent listing + chat
|
// Non-Tauri (browser) — use SaaS relay gateway client for agent listing + chat
|
||||||
const { createSaaSRelayGatewayClient } = await import('../lib/saas-relay-client');
|
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, () => {
|
const relayClient = createSaaSRelayGatewayClient(session.saasUrl, () => {
|
||||||
// 每次调用时读取 conversationStore 的 currentModel,fallback 到第一个可用模型
|
// 每次调用时读取 conversationStore 的 currentModel,fallback 到第一个可用模型
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user