mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(desktop): 修复桌面端多个渲染和权限问题
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
This commit is contained in:
parent
870e03d88b
commit
0b4c742d55
@ -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:"
|
||||
}
|
||||
},
|
||||
|
||||
@ -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<HTMLDivElement>(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 <code> 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 (
|
||||
<div ref={containerRef} className="code-viewer-area max-h-[420px] overflow-auto bg-[#FDFCF9]">
|
||||
{/* Plain-text fallback shown until Shiki finishes highlighting */}
|
||||
{!loaded && (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '0.5rem 12px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.45',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
color: '#24201E',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
)}
|
||||
<div style={loaded ? undefined : { position: 'absolute', opacity: 0, pointerEvents: 'none' }}>
|
||||
<ShikiHighlighter
|
||||
language={language || 'text'}
|
||||
theme={warmCodeTheme}
|
||||
showLineNumbers={showLineNumbers}
|
||||
showLanguage={false}
|
||||
addDefaultStyles={false}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '0.5rem 0',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.45',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
</div>
|
||||
|
||||
{/* Code area */}
|
||||
<div className="code-viewer-area max-h-[420px] overflow-auto bg-[#FDFCF9]">
|
||||
<ShikiHighlighter
|
||||
language={language || 'text'}
|
||||
theme={warmCodeTheme}
|
||||
showLineNumbers={effectiveShowLineNumbers}
|
||||
showLanguage={false}
|
||||
addDefaultStyles={false}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '0.5rem 0',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.45',
|
||||
}}
|
||||
>
|
||||
{visibleCode}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
<CodeArea
|
||||
code={visibleCode}
|
||||
language={language}
|
||||
showLineNumbers={effectiveShowLineNumbers}
|
||||
/>
|
||||
|
||||
{/* Expand/collapse toggle */}
|
||||
{showExpandToggle && (
|
||||
|
||||
@ -23,6 +23,7 @@ type SessionProcess = {
|
||||
proc: ReturnType<typeof Bun.spawn>
|
||||
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,
|
||||
|
||||
@ -305,12 +305,62 @@ function handleSetPermissionMode(
|
||||
message: Extract<ClientMessage, { type: 'set_permission_mode' }>
|
||||
) {
|
||||
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<WebSocketData>,
|
||||
sessionId: string,
|
||||
mode: string,
|
||||
): Promise<void> {
|
||||
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<WebSocketData>) {
|
||||
const { sessionId } = ws.data
|
||||
console.log(`[WS] Stop generation requested for session: ${sessionId}`)
|
||||
@ -386,6 +436,9 @@ type SessionStreamState = {
|
||||
hasReceivedStreamEvents: boolean
|
||||
activeBlockTypes: Map<number, 'text' | 'tool_use'>
|
||||
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: string }>
|
||||
/** Tool blocks whose input JSON failed to parse in content_block_stop.
|
||||
* The assistant message carries the complete input — defer to that. */
|
||||
pendingToolBlocks: Map<string, { toolName: string; toolUseId: string; parentToolUseId?: string }>
|
||||
}
|
||||
|
||||
const sessionStreamStates = new Map<string, SessionStreamState>()
|
||||
@ -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 []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user