From d2ec5131a352f065c4037ceaa76021ade70981bf Mon Sep 17 00:00:00 2001 From: xuxu777xu <1728019186@qq.com> Date: Sat, 7 Mar 2026 12:56:21 +0800 Subject: [PATCH] feat(chat): localize missing-directory SSE errors - add structured WORKING_DIR_NOT_FOUND handling from claude client to chat UI - sync current locale for non-React SSE formatting helpers - add unit coverage for translated SSE error formatting --- src/__tests__/unit/use-sse-stream.test.ts | 96 +++++++++++++++++++++++ src/app/chat/page.tsx | 11 ++- src/components/layout/I18nProvider.tsx | 6 +- src/hooks/useSSEStream.ts | 27 ++++++- src/i18n/en.ts | 1 + src/i18n/index.ts | 12 +++ src/i18n/zh.ts | 1 + src/lib/claude-client.ts | 45 +++++------ 8 files changed, 169 insertions(+), 30 deletions(-) create mode 100644 src/__tests__/unit/use-sse-stream.test.ts diff --git a/src/__tests__/unit/use-sse-stream.test.ts b/src/__tests__/unit/use-sse-stream.test.ts new file mode 100644 index 00000000..9d10680a --- /dev/null +++ b/src/__tests__/unit/use-sse-stream.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { consumeSSEStream, formatSSEError } from '../../hooks/useSSEStream'; + +describe('formatSSEError', () => { + const originalNavigator = globalThis.navigator; + + afterEach(() => { + if (originalNavigator) { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true, + }); + } else { + Reflect.deleteProperty(globalThis, 'navigator'); + } + }); + + it('should localize structured working directory errors with provided translator', () => { + const formatted = formatSSEError( + JSON.stringify({ + error_code: 'WORKING_DIR_NOT_FOUND', + working_directory: '/tmp/demo', + }), + (key, params) => `${key}:${params?.dir}` + ); + + assert.equal(formatted, 'error.workingDirNotFound:/tmp/demo'); + }); + + it('should fall back to locale detection when translator is omitted', () => { + Object.defineProperty(globalThis, 'navigator', { + value: { language: 'zh-CN' }, + configurable: true, + }); + + const formatted = formatSSEError( + JSON.stringify({ + error_code: 'WORKING_DIR_NOT_FOUND', + working_directory: '/tmp/demo', + }) + ); + + assert.equal(formatted, '项目目录不存在:/tmp/demo'); + }); + + it('should return plain error text unchanged', () => { + assert.equal(formatSSEError('plain error text'), 'plain error text'); + }); +}); + +describe('consumeSSEStream', () => { + it('should localize structured error events in the full consume path', async () => { + const encoder = new TextEncoder(); + let errorText = ''; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + type: 'error', + data: JSON.stringify({ + error_code: 'WORKING_DIR_NOT_FOUND', + working_directory: '/tmp/project', + }), + })}\n\n` + ) + ); + controller.close(); + }, + }); + + const result = await consumeSSEStream(stream.getReader(), { + onText: () => {}, + onToolUse: () => {}, + onToolResult: () => {}, + onToolOutput: () => {}, + onToolProgress: () => {}, + onStatus: () => {}, + onResult: () => {}, + onPermissionRequest: () => {}, + onToolTimeout: () => {}, + onModeChanged: () => {}, + onTaskUpdate: () => {}, + onKeepAlive: () => {}, + onError: (accumulated) => { + errorText = accumulated; + }, + }, (key, params) => `${key}:${params?.dir}`); + + assert.equal(errorText, '\n\n**Error:** error.workingDirNotFound:/tmp/project'); + assert.equal(result.accumulated, '\n\n**Error:** error.workingDirNotFound:/tmp/project'); + }); +}); diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index e85cf304..e38490ee 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -6,6 +6,8 @@ import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestE import { MessageList } from '@/components/chat/MessageList'; import { MessageInput } from '@/components/chat/MessageInput'; import { usePanel } from '@/hooks/usePanel'; +import { formatSSEError } from '@/hooks/useSSEStream'; +import { useTranslation } from '@/hooks/useTranslation'; interface ToolUseInfo { id: string; @@ -20,14 +22,15 @@ interface ToolResultInfo { export default function NewChatPage() { const router = useRouter(); - const { setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel(); + const { setPendingApprovalSessionId } = usePanel(); + const { t } = useTranslation(); const [messages, setMessages] = useState([]); const [streamingContent, setStreamingContent] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [toolUses, setToolUses] = useState([]); const [toolResults, setToolResults] = useState([]); const [statusText, setStatusText] = useState(); - const [workingDir, setWorkingDir] = useState(''); + const [workingDir] = useState(''); const [mode, setMode] = useState('code'); const [currentModel, setCurrentModel] = useState('sonnet'); const [currentProviderId, setCurrentProviderId] = useState(''); @@ -252,7 +255,7 @@ export default function NewChatPage() { break; } case 'error': { - accumulated += '\n\n**Error:** ' + event.data; + accumulated += '\n\n**Error:** ' + formatSSEError(event.data, t); setStreamingContent(accumulated); break; } @@ -311,7 +314,7 @@ export default function NewChatPage() { abortControllerRef.current = null; } }, - [isStreaming, router, workingDir, mode, currentModel, currentProviderId, setPendingApprovalSessionId] + [isStreaming, router, workingDir, mode, currentModel, currentProviderId, setPendingApprovalSessionId, t] ); const handleCommand = useCallback((command: string) => { diff --git a/src/components/layout/I18nProvider.tsx b/src/components/layout/I18nProvider.tsx index c8723ed3..18eb1582 100644 --- a/src/components/layout/I18nProvider.tsx +++ b/src/components/layout/I18nProvider.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react'; -import { type Locale, type TranslationKey, translate } from '@/i18n'; +import { type Locale, type TranslationKey, translate, setCurrentLocale } from '@/i18n'; interface I18nContextValue { locale: Locale; @@ -37,6 +37,10 @@ export function I18nProvider({ children }: { children: ReactNode }) { loadLocale(); }, []); + useEffect(() => { + setCurrentLocale(locale); + }, [locale]); + const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale); // Persist to app settings diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index d3130731..9fb8826a 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -1,5 +1,23 @@ import { useRef, useCallback } from 'react'; import type { SSEEvent, TokenUsage, PermissionRequestEvent } from '@/types'; +import { translate, detectLocale, type TranslationKey } from '@/i18n'; + +export type TranslationFn = (key: TranslationKey, params?: Record) => string; + +export function formatSSEError(eventData: string, t?: TranslationFn): string { + try { + const parsed = JSON.parse(eventData); + if (parsed.error_code === 'WORKING_DIR_NOT_FOUND') { + const translator = t ?? ((key: TranslationKey, params?: Record) => + translate(detectLocale(), key, params)); + return translator('error.workingDirNotFound', { dir: parsed.working_directory }); + } + } catch { + // ignore + } + + return eventData; +} interface ToolUseInfo { id: string; @@ -36,6 +54,7 @@ function handleSSEEvent( event: SSEEvent, accumulated: string, callbacks: SSECallbacks, + t?: TranslationFn, ): string { switch (event.type) { case 'text': { @@ -153,7 +172,7 @@ function handleSSEEvent( } case 'error': { - const next = accumulated + '\n\n**Error:** ' + event.data; + const next = accumulated + '\n\n**Error:** ' + formatSSEError(event.data, t); callbacks.onError(next); return next; } @@ -174,6 +193,7 @@ function handleSSEEvent( export async function consumeSSEStream( reader: ReadableStreamDefaultReader, callbacks: SSECallbacks, + t?: TranslationFn, ): Promise<{ accumulated: string; tokenUsage: TokenUsage | null }> { const decoder = new TextDecoder(); let buffer = ''; @@ -201,7 +221,7 @@ export async function consumeSSEStream( try { const event: SSEEvent = JSON.parse(line.slice(6)); - accumulated = handleSSEEvent(event, accumulated, wrappedCallbacks); + accumulated = handleSSEEvent(event, accumulated, wrappedCallbacks, t); } catch { // skip malformed SSE lines } @@ -222,6 +242,7 @@ export function useSSEStream() { async ( reader: ReadableStreamDefaultReader, callbacks: SSECallbacks, + t?: TranslationFn, ) => { callbacksRef.current = callbacks; @@ -242,7 +263,7 @@ export function useSSEStream() { onError: (a) => callbacksRef.current?.onError(a), }; - return consumeSSEStream(reader, proxied); + return consumeSSEStream(reader, proxied, t); }, [], ); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0413ffce..7d38a5fc 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -331,6 +331,7 @@ const en = { 'error.hideDetails': 'Hide details', 'error.tryAgain': 'Try Again', 'error.reloadApp': 'Reload App', + 'error.workingDirNotFound': 'Project directory not found: {dir}', // ── Update ───────────────────────────────────────────────── 'update.newVersionAvailable': 'New Version Available', diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 4719a8dc..3b6a5e46 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -5,6 +5,8 @@ export type { TranslationKey }; export type Locale = 'en' | 'zh'; +let currentLocale: Locale | null = null; + export const SUPPORTED_LOCALES: { value: Locale; label: string }[] = [ { value: 'en', label: 'English' }, { value: 'zh', label: '中文' }, @@ -15,6 +17,16 @@ const dictionaries: Record> = { zh, }; +export function setCurrentLocale(locale: Locale): void { + currentLocale = locale; +} + +export function detectLocale(): Locale { + if (currentLocale) return currentLocale; + if (typeof navigator !== 'undefined' && navigator.language.startsWith('zh')) return 'zh'; + return 'en'; +} + /** * Translate a key with optional parameter interpolation. * Fallback chain: target locale → English → raw key. diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 46833814..53e7cdc8 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -328,6 +328,7 @@ const zh: Record = { 'error.hideDetails': '隐藏详情', 'error.tryAgain': '重试', 'error.reloadApp': '重新加载', + 'error.workingDirNotFound': '项目目录不存在:{dir}', // ── Update ───────────────────────────────────────────────── 'update.newVersionAvailable': '有新版本可用', diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index afde9aae..ac92a258 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -1,6 +1,5 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import type { - SDKMessage, SDKAssistantMessage, SDKUserMessage, SDKResultMessage, @@ -31,7 +30,6 @@ import path from 'path'; * Removes null bytes and control characters that cause spawn EINVAL. */ function sanitizeEnvValue(value: string): string { - // eslint-disable-next-line no-control-regex return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); } @@ -165,19 +163,6 @@ function formatSSE(event: SSEEvent): string { return `data: ${JSON.stringify(event)}\n\n`; } -/** - * Extract text content from an SDK assistant message - */ -function extractTextFromMessage(msg: SDKAssistantMessage): string { - const parts: string[] = []; - for (const block of msg.message.content) { - if (block.type === 'text') { - parts.push(block.text); - } - } - return parts.join(''); -} - /** * Extract token usage from an SDK result message */ @@ -363,6 +348,20 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream