From 0b4c742d5572059046a20eb0406d37651d227e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 16 Apr 2026 10:50:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E4=BF=AE=E5=A4=8D=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E7=AB=AF=E5=A4=9A=E4=B8=AA=E6=B8=B2=E6=9F=93=E5=92=8C?= =?UTF-8?q?=E6=9D=83=E9=99=90=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复 Tauri CSP 导致 Emotion CSS-in-JS 样式失效的问题 - Tauri 编译时自动给 style-src 添加 nonce,使 unsafe-inline 失效 - 添加 dangerousDisableAssetCspModification 阻止 Tauri 修改 style-src - 解决 DiffViewer 背景色/缩进等样式丢失 2. 修复 CodeViewer 代码块内容不显示的问题 - react-shiki 异步加载 WASM 期间返回 null,代码区域空白 - 添加 CodeArea 组件,纯文本 fallback + MutationObserver 检测加载完成 3. 修复 Bypass 权限模式切换不生效的问题 - CLI 的 bypassPermissions 需要启动时带 --dangerously-skip-permissions - 运行中切换被 CLI 静默拒绝,前端不知情 - 改为重启 CLI 子进程以正确的参数启动 4. 修复 stream_event tool input JSON 解析失败时 DiffViewer 不渲染 - content_block_stop 的 JSON parse 失败时暂存到 pendingToolBlocks - 由后续 assistant 消息补发完整的 tool input --- desktop/src-tauri/tauri.conf.json | 1 + desktop/src/components/chat/CodeViewer.tsx | 94 +++++++++++++++---- src/server/services/conversationService.ts | 12 +++ src/server/ws/handler.ts | 103 +++++++++++++++++++-- 4 files changed, 181 insertions(+), 29 deletions(-) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 536e0f91..75b079a6 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -25,6 +25,7 @@ } ], "security": { + "dangerousDisableAssetCspModification": ["style-src"], "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:" } }, diff --git a/desktop/src/components/chat/CodeViewer.tsx b/desktop/src/components/chat/CodeViewer.tsx index 947519ee..36238c72 100644 --- a/desktop/src/components/chat/CodeViewer.tsx +++ b/desktop/src/components/chat/CodeViewer.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { ShikiHighlighter } from 'react-shiki' import 'react-shiki/css' import { CopyButton } from '../shared/CopyButton' @@ -45,6 +45,75 @@ const warmCodeTheme = { ], } +/** + * Wraps ShikiHighlighter with a plain-text fallback so the code area + * is never empty while the async WASM / language-grammar load is in-flight, + * or if highlighting fails entirely. + */ +function CodeArea({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) { + const containerRef = useRef(null) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + // ShikiHighlighter renders `null` until the async highlight completes. + // Watch for real content appearing via MutationObserver so we can hide + // the plain-text fallback as soon as highlighted output is in the DOM. + const el = containerRef.current + if (!el) return + const check = () => { + const shikiContainer = el.querySelector('[data-testid="shiki-container"]') + // shiki renders a element inside its container once highlighting is done + if (shikiContainer?.querySelector('code')) { + setLoaded(true) + } + } + check() + const observer = new MutationObserver(check) + observer.observe(el, { childList: true, subtree: true }) + return () => observer.disconnect() + }, [code, language]) + + return ( +
+ {/* Plain-text fallback shown until Shiki finishes highlighting */} + {!loaded && ( +
+          {code}
+        
+ )} +
+ + {code} + +
+
+ ) +} + export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = true }: Props) { const [expanded, setExpanded] = useState(false) @@ -72,24 +141,11 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = tr {/* Code area */} -
- - {visibleCode} - -
+ {/* Expand/collapse toggle */} {showExpandToggle && ( diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index cb03e899..a30935f9 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -23,6 +23,7 @@ type SessionProcess = { proc: ReturnType outputCallbacks: Array<(msg: any) => void> workDir: string + permissionMode: string sdkToken: string sdkSocket: { send(data: string): void } | null pendingOutbound: string[] @@ -142,6 +143,7 @@ export class ConversationService { proc, outputCallbacks: [], workDir, + permissionMode: options?.permissionMode || 'default', sdkToken: this.getSdkTokenFromUrl(sdkUrl), sdkSocket: null, pendingOutbound: [], @@ -285,6 +287,16 @@ export class ConversationService { return this.sessions.has(sessionId) } + getSessionWorkDir(sessionId: string): string { + const session = this.sessions.get(sessionId) + return session?.workDir || '' + } + + getSessionPermissionMode(sessionId: string): string { + const session = this.sessions.get(sessionId) + return session?.permissionMode || 'default' + } + authorizeSdkConnection( sessionId: string, token: string | null | undefined, diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index eed6b2d5..20986103 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -305,12 +305,62 @@ function handleSetPermissionMode( message: Extract ) { const { sessionId } = ws.data + + // Switching to/from bypassPermissions requires the CLI to be (re)started with + // --dangerously-skip-permissions. The CLI rejects a runtime set_permission_mode + // to bypassPermissions if it wasn't launched with that flag. Rather than just + // sending the SDK message (which would silently fail), restart the CLI subprocess + // with the correct arguments so the new permission mode takes effect. + const needsRestart = + conversationService.hasSession(sessionId) && + (message.mode === 'bypassPermissions' || conversationService.getSessionPermissionMode(sessionId) === 'bypassPermissions') + + if (needsRestart) { + void restartSessionWithPermissionMode(ws, sessionId, message.mode) + return + } + const ok = conversationService.setPermissionMode(sessionId, message.mode) if (!ok) { console.warn(`[WS] Ignored permission mode update for inactive session ${sessionId}`) } } +async function restartSessionWithPermissionMode( + ws: ServerWebSocket, + sessionId: string, + mode: string, +): Promise { + try { + sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Restarting session with new permissions...' }) + + // Persist the new mode first so it's read on restart + await settingsService.setPermissionMode(mode) + + const workDir = conversationService.getSessionWorkDir(sessionId) + conversationService.stopSession(sessionId) + + // Rebuild runtime settings (will pick up the persisted mode) + const runtimeSettings = await getRuntimeSettings() + const sdkUrl = + `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + + `?token=${encodeURIComponent(crypto.randomUUID())}` + await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings) + + sendMessage(ws, { type: 'status', state: 'idle' }) + console.log(`[WS] Restarted CLI for ${sessionId} with permission mode: ${mode}`) + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`) + sendMessage(ws, { + type: 'error', + message: `Failed to restart session with new permission mode: ${errMsg}`, + code: 'CLI_RESTART_FAILED', + }) + sendMessage(ws, { type: 'status', state: 'idle' }) + } +} + function handleStopGeneration(ws: ServerWebSocket) { const { sessionId } = ws.data console.log(`[WS] Stop generation requested for session: ${sessionId}`) @@ -386,6 +436,9 @@ type SessionStreamState = { hasReceivedStreamEvents: boolean activeBlockTypes: Map activeToolBlocks: Map + /** Tool blocks whose input JSON failed to parse in content_block_stop. + * The assistant message carries the complete input — defer to that. */ + pendingToolBlocks: Map } const sessionStreamStates = new Map() @@ -397,6 +450,7 @@ function getStreamState(sessionId: string): SessionStreamState { hasReceivedStreamEvents: false, activeBlockTypes: new Map(), activeToolBlocks: new Map(), + pendingToolBlocks: new Map(), } sessionStreamStates.set(sessionId, state) } @@ -427,7 +481,20 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { for (const block of cliMsg.message.content) { if (streamState.hasReceivedStreamEvents) { - // Everything was already sent via stream_event — skip all blocks + // Stream events handled most blocks — but any tool_use whose + // input JSON failed to parse in content_block_stop was deferred. + // Emit those now with the complete input from the assistant message. + if (block.type === 'tool_use' && streamState.pendingToolBlocks.has(block.id)) { + const pending = streamState.pendingToolBlocks.get(block.id)! + streamState.pendingToolBlocks.delete(block.id) + messages.push({ + type: 'tool_use_complete', + toolName: pending.toolName || block.name, + toolUseId: block.id, + input: block.input, + parentToolUseId: pending.parentToolUseId, + }) + } } else { // No stream events received — this is the only source, process everything if (block.type === 'thinking' && block.thinking) { @@ -450,8 +517,9 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { } } - // Reset flag for next turn + // Reset flags for next turn streamState.hasReceivedStreamEvents = false + streamState.pendingToolBlocks.clear() return messages } return [] @@ -549,18 +617,33 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { const toolBlock = streamState.activeToolBlocks.get(index) streamState.activeToolBlocks.delete(index) if (toolBlock) { + const parentToolUseId = + typeof cliMsg.parent_tool_use_id === 'string' + ? cliMsg.parent_tool_use_id + : undefined let parsedInput = null try { parsedInput = JSON.parse(toolBlock.inputJson) } catch {} - return [{ - type: 'tool_use_complete', + + if (parsedInput !== null) { + return [{ + type: 'tool_use_complete', + toolName: toolBlock.toolName, + toolUseId: toolBlock.toolUseId, + input: parsedInput, + parentToolUseId, + }] + } + + // JSON parse failed — defer to the assistant message which + // carries the complete, already-parsed tool input. + console.warn( + `[WS] Tool input JSON parse failed for ${toolBlock.toolName} (${toolBlock.toolUseId}), deferring to assistant message`, + ) + streamState.pendingToolBlocks.set(toolBlock.toolUseId, { toolName: toolBlock.toolName, toolUseId: toolBlock.toolUseId, - input: parsedInput, - parentToolUseId: - typeof cliMsg.parent_tool_use_id === 'string' - ? cliMsg.parent_tool_use_id - : undefined, - }] + parentToolUseId, + }) } } return []