From 9758ae8a3a66545bec748ffba76e79d0f2c502f0 Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Thu, 16 Apr 2026 16:00:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=B8=8D=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=EF=BC=8C=E8=80=8C=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E4=BD=BF=E7=94=A8i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/workspace/chats/[thread_id]/page.tsx | 42 +++++---- .../components/ai-elements/prompt-input.tsx | 8 +- .../artifacts/artifact-file-detail.tsx | 74 ++++++++++----- .../artifacts/artifact-file-list.tsx | 2 +- .../workspace/artifacts/artifact-trigger.tsx | 2 +- .../components/workspace/chats/chat-box.tsx | 8 +- .../src/components/workspace/input-box.tsx | 51 +++++++---- .../workspace/messages/message-list-item.tsx | 16 ++-- .../workspace/messages/message-list.tsx | 2 +- .../components/workspace/workspace-header.tsx | 2 +- frontend/src/core/i18n/locales/en-US.ts | 89 +++++++++++++++++++ frontend/src/core/i18n/locales/types.ts | 82 +++++++++++++++++ frontend/src/core/i18n/locales/zh-CN.ts | 85 ++++++++++++++++++ frontend/src/core/models/hooks.ts | 9 +- frontend/src/core/threads/hooks.ts | 29 +++--- frontend/src/hooks/use-iframe-skill.ts | 28 +++--- .../src/hooks/use-selected-skill-listener.ts | 24 ++--- 17 files changed, 441 insertions(+), 112 deletions(-) diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 04c16730..aa7fc0e0 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -94,7 +94,7 @@ export default function ChatPage() { const currentSlogan = motivationSlogans[ sloganIndex % motivationSlogans.length ] ?? { - text: "来,一起学习工作吧", + text: t.chatPage.defaultSlogan, color: "#333333", }; const tickerCharacterList = useMemo(() => { @@ -133,7 +133,7 @@ export default function ChatPage() { if (!safeThreadId) { if (!warnedMissingThreadIdRef.current) { warnedMissingThreadIdRef.current = true; - toast.error("缺少 thread_id,无法创建会话"); + toast.error(t.chatPage.missingThreadIdForCreate); } return; } @@ -156,9 +156,15 @@ export default function ChatPage() { }) .catch(() => { initializedThreadRef.current = null; - toast.error("会话创建失败,请稍后重试"); + toast.error(t.chatPage.createSessionFailed); }); - }, [apiClient, isNewThread, safeThreadId]); + }, [ + apiClient, + isNewThread, + safeThreadId, + t.chatPage.createSessionFailed, + t.chatPage.missingThreadIdForCreate, + ]); // 监听宿主页 selectedSkill 消息 const { @@ -183,7 +189,7 @@ export default function ChatPage() { }, onFinish: (state) => { if (document.hidden || !document.hasFocus()) { - let body = "Conversation finished"; + let body = t.chatPage.conversationFinished; const lastMessage = state.messages.at(-1); if (lastMessage) { const textContent = textOfMessage(lastMessage); @@ -235,12 +241,13 @@ export default function ChatPage() { ? thread.values.title : t.pages.untitled; if (thread.isThreadLoading) { - document.title = `Loading... - ${t.pages.appName}`; + document.title = `${t.common.loading} - ${t.pages.appName}`; } else { document.title = `${pageTitle} - ${t.pages.appName}`; } }, [ isNewThread, + t.common.loading, t.pages.newChat, t.pages.untitled, t.pages.appName, @@ -283,7 +290,7 @@ export default function ChatPage() { return; } if (isNewThread && !safeThreadId) { - toast.error("缺少 thread_id,无法发送消息"); + toast.error(t.chatPage.missingThreadIdForSend); return; } setHasSubmitted(true); @@ -299,6 +306,7 @@ export default function ChatPage() { safeThreadId, sendMessage, showWelcomeStyle, + t.chatPage.missingThreadIdForSend, ], ); const handleStop = useCallback(async () => { @@ -407,7 +415,7 @@ export default function ChatPage() { /> */} {artifacts?.length > 0 && !artifactsOpen && ( - + @@ -647,11 +655,11 @@ export default function ChatPage() { - ⚠️ {selectedSkillError?.title ?? "技能加载失败"} + ⚠️ {selectedSkillError?.title ?? t.chatPage.selectedSkillLoadFailed}

- {selectedSkillError?.message ?? "发生了未知错误,请稍后重试。"} + {selectedSkillError?.message ?? t.chatPage.unknownErrorRetry}

diff --git a/frontend/src/components/ai-elements/prompt-input.tsx b/frontend/src/components/ai-elements/prompt-input.tsx index f43fedc5..193ae372 100644 --- a/frontend/src/components/ai-elements/prompt-input.tsx +++ b/frontend/src/components/ai-elements/prompt-input.tsx @@ -1153,19 +1153,19 @@ export const PromptInputSubmit = ({ let Icon = ; - let text: string = "发送"; + let text: string = t.inputBox.submit; if (status === "submitted") { Icon = ; - text = "生成中..."; + text = t.inputBox.submitting; } else if (status === "streaming") { Icon = ; - text = "停止"; + text = t.inputBox.stop; } else if (status === "error") { // 没有报错状态,先用error状态代替 Icon = ; // MARK: 这里后端没有返回错误信息,先写死一个文本 - text = "发送"; + text = t.inputBox.submit; } return ( diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 9b5a7e69..ab5b0c74 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -210,8 +210,18 @@ export function ArtifactFileDetail({ artifactUrl, fileName, kind: artifactPreviewKind, + pdfPreviewMessage: t.artifactPreview.pdfPreviewFailed, + unsupportedTypeMessage: t.artifactPreview.unsupportedType, + openInNewTabLabel: t.artifactPreview.openInNewTab, }); - }, [artifactUrl, fileName, artifactPreviewKind]); + }, [ + artifactUrl, + fileName, + artifactPreviewKind, + t.artifactPreview.openInNewTab, + t.artifactPreview.pdfPreviewFailed, + t.artifactPreview.unsupportedType, + ]); // Native PDF iframe rendering is intentionally disabled; PDFs are rendered via pdf.js. const artifactViewerSrc = useMemo(() => { return undefined; @@ -954,6 +964,7 @@ function ArtifactPdfPreview({ artifactUrl: string; fileName: string; }) { + const { t } = useI18n(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [pageCount, setPageCount] = useState(0); @@ -1033,7 +1044,7 @@ function ArtifactPdfPreview({ } catch (err) { console.error("Failed to render pdf preview:", err); if (!disposed) { - setError("无法预览该 PDF 文件,请下载后查看。"); + setError(t.artifactPreview.pdfPreviewFailed); } } finally { if (!disposed) { @@ -1047,7 +1058,7 @@ function ArtifactPdfPreview({ return () => { disposed = true; }; - }, [artifactUrl]); + }, [artifactUrl, t.artifactPreview.pdfPreviewFailed]); if (error) { return ( @@ -1061,7 +1072,7 @@ function ArtifactPdfPreview({ target="_blank" rel="noopener noreferrer" > - 在新标签页打开 + {t.artifactPreview.openInNewTab} @@ -1071,7 +1082,9 @@ function ArtifactPdfPreview({ return (
- {pageCount > 0 ? `${fileName} · ${pageCount} page(s)` : fileName} + {pageCount > 0 + ? t.artifactPreview.pageCountLabel(fileName, pageCount) + : fileName}
{isLoading && ( @@ -1094,6 +1107,7 @@ function ArtifactOfficePreview({ artifactUrl: string; fileName: string; }) { + const { t } = useI18n(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [sheetNames, setSheetNames] = useState([]); @@ -1138,7 +1152,7 @@ function ArtifactOfficePreview({ } catch (err) { console.error("Failed to render docx preview:", err); if (!disposed) { - setError("无法预览该 DOCX 文件。"); + setError(t.artifactPreview.docxPreviewFailed); } } finally { if (!disposed) { @@ -1151,7 +1165,7 @@ function ArtifactOfficePreview({ return () => { disposed = true; }; - }, [artifactUrl, canRenderDocx]); + }, [artifactUrl, canRenderDocx, t.artifactPreview.docxPreviewFailed]); useEffect(() => { let disposed = false; @@ -1186,7 +1200,7 @@ function ArtifactOfficePreview({ } catch (err) { console.error("Failed to render xlsx preview:", err); if (!disposed) { - setError("无法预览该 Excel 文件。"); + setError(t.artifactPreview.excelPreviewFailed); } } finally { if (!disposed) { @@ -1199,7 +1213,7 @@ function ArtifactOfficePreview({ return () => { disposed = true; }; - }, [artifactUrl, canRenderXlsx]); + }, [artifactUrl, canRenderXlsx, t.artifactPreview.excelPreviewFailed]); useEffect(() => { if (!canRenderXlsx || !activeSheet || !workbookRef.current) { @@ -1213,9 +1227,9 @@ function ArtifactOfficePreview({ setXlsxRows(rows); } catch (err) { console.error("Failed to switch xlsx sheet:", err); - setError("切换工作表失败。"); + setError(t.artifactPreview.switchSheetFailed); } - }, [activeSheet, canRenderXlsx]); + }, [activeSheet, canRenderXlsx, t.artifactPreview.switchSheetFailed]); useEffect(() => { if (!canRenderXlsx || !xlsxGridContainerRef.current) { @@ -1247,7 +1261,7 @@ function ArtifactOfficePreview({ } catch (err) { console.error("Failed to render RevoGrid preview:", err); if (!disposed) { - setError("无法渲染 Excel 网格预览。"); + setError(t.artifactPreview.excelGridPreviewFailed); } } } @@ -1257,14 +1271,19 @@ function ArtifactOfficePreview({ return () => { disposed = true; }; - }, [canRenderXlsx, xlsxColumns, xlsxRows]); + }, [ + canRenderXlsx, + xlsxColumns, + xlsxRows, + t.artifactPreview.excelGridPreviewFailed, + ]); useEffect(() => { if (!canRenderPptx) { return; } setIsLoading(false); - setError("请下载ppt文件以获得最佳效果"); - }, [canRenderPptx]); + setError(t.artifactPreview.pptxDownloadHint); + }, [canRenderPptx, t.artifactPreview.pptxDownloadHint]); return (
@@ -1328,6 +1347,7 @@ function ArtifactPreviewFallback({ fileName: string; artifactUrl: string; }) { + const { t } = useI18n(); return (

{fileName}

@@ -1338,7 +1358,7 @@ function ArtifactPreviewFallback({ target="_blank" rel="noreferrer" > - 点击下载 + {t.artifactPreview.clickToDownload}
); @@ -1459,13 +1479,22 @@ function buildArtifactViewerSrcDoc({ artifactUrl, fileName, kind, + pdfPreviewMessage, + unsupportedTypeMessage, + openInNewTabLabel, }: { artifactUrl: string; fileName: string; kind: ArtifactPreviewKind; + pdfPreviewMessage: string; + unsupportedTypeMessage: string; + openInNewTabLabel: string; }) { const safeUrl = escapeHtml(artifactUrl); const safeName = escapeHtml(fileName); + const safePdfPreviewMessage = escapeHtml(pdfPreviewMessage); + const safeUnsupportedTypeMessage = escapeHtml(unsupportedTypeMessage); + const safeOpenInNewTabLabel = escapeHtml(openInNewTabLabel); const content = (() => { if (kind === "image") { @@ -1480,8 +1509,8 @@ function buildArtifactViewerSrcDoc({ if (kind === "pdf") { return `

${safeName}

-

PDF preview is temporarily disabled. Please download the file to view it.

- Open in new tab +

${safePdfPreviewMessage}

+ ${safeOpenInNewTabLabel}
`; } if (kind === "html") { @@ -1489,8 +1518,8 @@ function buildArtifactViewerSrcDoc({ } return `

${safeName}

-

This file type is not previewable in the custom viewer.

- Open in new tab +

${safeUnsupportedTypeMessage}

+ ${safeOpenInNewTabLabel}
`; })(); @@ -1622,6 +1651,7 @@ export const ArtifactZoomSelector = ({ className, ...props }: ArtifactZoomSelectorProps) => { + const { t } = useI18n(); const handleZoomIn = () => { const currentIndex = ZOOM_LEVELS.indexOf(value); const nextValue = ZOOM_LEVELS[currentIndex + 1]; @@ -1660,7 +1690,7 @@ export const ArtifactZoomSelector = ({ "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent", "dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground", )} - aria-label="放大" + aria-label={t.artifactPreview.zoomIn} > - 引用 + {t.common.reference} diff --git a/frontend/src/components/workspace/artifacts/artifact-trigger.tsx b/frontend/src/components/workspace/artifacts/artifact-trigger.tsx index df1fe684..76f9fc4c 100644 --- a/frontend/src/components/workspace/artifacts/artifact-trigger.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-trigger.tsx @@ -14,7 +14,7 @@ export const ArtifactTrigger = () => { return null; } return ( - +
-

Artifacts

+

{t.common.artifacts}

void; }) { const { t } = useI18n(); + const referenceSourceLabels = useMemo( + () => ({ + artifact: t.inputBox.referenceSourceArtifact, + upload: t.inputBox.referenceSourceUpload, + }), + [t], + ); const { thread } = useThread(); const searchParams = useSearchParams(); const iframeSkill = useIframeSkill({ threadId: threadIdFromProps }); @@ -280,7 +282,7 @@ export function InputBox({ pathTail: getPathTail(path), ref_source: "artifact" as const, ref_kind: "mention" as const, - typeLabel: REFERENCE_SOURCE_LABELS.artifact, + typeLabel: referenceSourceLabels.artifact, isImage: isImageFilename(filename), previewUrl: threadId ? urlOfArtifact({ @@ -299,7 +301,7 @@ export function InputBox({ pathTail: getPathTail(file.virtual_path), ref_source: "upload" as const, ref_kind: "mention" as const, - typeLabel: REFERENCE_SOURCE_LABELS.upload, + typeLabel: referenceSourceLabels.upload, isImage: isImageFilename(file.filename), previewUrl: file.artifact_url, })) ?? []; @@ -309,7 +311,13 @@ export function InputBox({ deduped.set(candidate.key, candidate); }); return [...deduped.values()]; - }, [thread.values.artifacts, uploadedFilesData?.files, threadId]); + }, [ + referenceSourceLabels.artifact, + referenceSourceLabels.upload, + thread.values.artifacts, + uploadedFilesData?.files, + threadId, + ]); const filteredMentionCandidates = useMemo(() => { const query = mentionQuery.trim().toLowerCase(); @@ -383,12 +391,12 @@ export function InputBox({ return prev; } if (prev.length >= MAX_REFERENCES_PER_MESSAGE) { - toast.error("单条消息最多引用 10 个文件"); + toast.error(t.inputBox.maxReferencesReached); return prev; } return prev.concat(reference); }); - }, []); + }, [t.inputBox.maxReferencesReached]); const selectMentionCandidate = useCallback( (candidate: MentionCandidate) => { @@ -687,7 +695,7 @@ export function InputBox({ }} > - 添加引用 + {t.inputBox.addReference} @@ -842,7 +850,7 @@ export function InputBox({
{followupsLoading ? (
- 加载中... + {t.inputBox.followupLoading}
) : ( @@ -872,19 +880,21 @@ export function InputBox({ - 提示 + {t.inputBox.followupConfirmTitle} - 请确认要如何处理当前的追加建议内容? + {t.inputBox.followupConfirmDescription} + - @@ -1165,6 +1175,11 @@ function AttachmentPreviewBar({ threadId: string; onRemoveReference: (reference: PromptInputReference) => void; }) { + const { t } = useI18n(); + const referenceSourceLabels = { + artifact: t.inputBox.referenceSourceArtifact, + upload: t.inputBox.referenceSourceUpload, + } as const; const attachments = usePromptInputAttachments(); const hasReferences = references.length > 0; const hasAttachmentFiles = attachments.files.length > 0; @@ -1214,7 +1229,7 @@ function AttachmentPreviewBar({ }} data-testid="reference-chip" onRemove={() => onRemoveReference(reference)} - title={`${REFERENCE_SOURCE_LABELS[reference.ref_source]}${reference.path ? ` · ${getPathTail(reference.path)}` : ""}`} + title={`${referenceSourceLabels[reference.ref_source]}${reference.path ? ` · ${getPathTail(reference.path)}` : ""}`} /> ); })} diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 6b921bcd..98cc5687 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -387,11 +387,15 @@ function RichFileCard({ clear_target: true, }); setMaterializeMessage( - `已创建 ${result.created_files} 个文件 / ${result.created_directories} 个目录`, + t.messageListItem.materializeSuccess( + result.created_files, + result.created_directories, + ), ); } catch (error) { - const message = error instanceof Error ? error.message : "解析失败"; - setMaterializeMessage(`失败: ${message}`); + const message = + error instanceof Error ? error.message : t.messageListItem.parseFailed; + setMaterializeMessage(t.messageListItem.materializeFailed(message)); } finally { setIsMaterializing(false); } @@ -430,7 +434,7 @@ function RichFileCard({ }); }} > - 引用 + {t.common.reference} @@ -470,7 +474,9 @@ function RichFileCard({ }} disabled={isMaterializing} > - {isMaterializing ? "解析中..." : "一键导入为 Skill 目录"} + {isMaterializing + ? t.messageListItem.materializing + : t.messageListItem.importAsSkillDir} {materializeMessage && ( diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 8bb5bbf2..d670292d 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -228,7 +228,7 @@ export function MessageList({ "z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm", scrollButtonClassName, )} - title="滚动到底部" + title={t.chats.scrollToBottom} /> )} diff --git a/frontend/src/components/workspace/workspace-header.tsx b/frontend/src/components/workspace/workspace-header.tsx index 5a8ee06e..be4128ff 100644 --- a/frontend/src/components/workspace/workspace-header.tsx +++ b/frontend/src/components/workspace/workspace-header.tsx @@ -38,7 +38,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? ( - XClaw侧边栏 + {t.workspaceHeader.sidebarTitle} ) : (
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index cf40ab68..0e756541 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -52,6 +52,7 @@ export const enUS: Translations = { exportAsJSON: "Export as JSON", exportSuccess: "Conversation exported", removeAttachment: "Remove attachment", + reference: "Reference", }, // Welcome @@ -115,6 +116,13 @@ export const enUS: Translations = { "You already have text in the input. Choose how to send it.", followupConfirmAppend: "Append & send", followupConfirmReplace: "Replace & send", + submit: "Send", + submitting: "Generating...", + stop: "Stop", + addReference: "Add reference", + referenceSourceArtifact: "Generated file", + referenceSourceUpload: "Uploaded attachment", + maxReferencesReached: "You can reference up to 10 files per message", suggestions: [ { suggestion: "Paper Writing", @@ -248,6 +256,87 @@ export const enUS: Translations = { // Chats chats: { searchChats: "Search chats", + scrollToBottom: "Scroll to bottom", + }, + + // Workspace Chat Page + chatPage: { + defaultSlogan: "Let's study and work together", + missingThreadIdForCreate: "Missing thread_id, cannot create session", + createSessionFailed: "Failed to create session, please try again later", + conversationFinished: "Conversation finished", + missingThreadIdForSend: "Missing thread_id, cannot send message", + viewArtifactsTooltip: "Click to view generated artifacts", + noArtifactSelectedTitle: "No artifact selected", + noArtifactSelectedDescription: "Select an artifact to view its details", + exitDialogTitle: "Notice", + exitDialogDescription: + "Chat history is automatically deleted every seven days. You will return to the welcome page now. Continue?", + exitDialogConfirm: "Confirm", + selectedSkillLoadFailed: "Failed to load skill", + unknownErrorRetry: "An unknown error occurred. Please try again later.", + }, + + messageListItem: { + materializing: "Parsing...", + importAsSkillDir: "Import as Skill directory", + materializeSuccess: (files: number, directories: number) => + `Created ${files} file(s) / ${directories} director${directories === 1 ? "y" : "ies"}`, + parseFailed: "Parse failed", + materializeFailed: (message: string) => `Failed: ${message}`, + }, + + artifactPreview: { + pdfPreviewFailed: "Unable to preview this PDF file. Please download it.", + unsupportedType: "This file type is not previewable in the custom viewer.", + docxPreviewFailed: "Unable to preview this DOCX file.", + excelPreviewFailed: "Unable to preview this Excel file.", + switchSheetFailed: "Failed to switch worksheet.", + excelGridPreviewFailed: "Unable to render Excel grid preview.", + pptxDownloadHint: "Please download the PPT file for the best experience.", + openInNewTab: "Open in new tab", + clickToDownload: "Click to download", + pageCountLabel: (fileName: string, pageCount: number) => + `${fileName} · ${pageCount} page(s)`, + zoomIn: "Zoom in", + zoomOut: "Zoom out", + showArtifactsTooltip: "Show artifacts of this conversation", + }, + + workspaceHeader: { + sidebarTitle: "XClaw Sidebar", + }, + + models: { + updating: "System is updating, please wait...", + apiUnavailable: + "Model API is unavailable. Please check backend routes or service status.", + }, + + threads: { + streamError: "Something went wrong.", + invalidThreadId: "Invalid thread id 'new'. Please refresh and retry.", + staleReferencesRemoved: + "Some referenced files were invalid and were removed automatically.", + uploadFailed: "Failed to upload files.", + uploadPrepareFailed: (count: number) => + `Failed to prepare ${count} attachment(s) for upload. Please retry.`, + threadNotReadyForUpload: "Thread is not ready for file upload.", + }, + + skills: { + loadFailed: "Failed to load skill", + missingThreadId: "Missing thread_id, cannot initialize skill", + invalidSkillId: "Invalid skill_id", + loading: (title: string) => `Loading skill "${title}"...`, + loadFailedWithTitle: (title: string) => `Failed to load skill "${title}"`, + loadSuccessWithTitle: (title: string) => + `Skill "${title}" loaded successfully`, + loadErrorWithTitle: (title: string) => `Error loading skill "${title}"`, + unknownError: "Unknown error", + networkRequestFailed: "Network request failed", + createdFiles: (count: number) => `Created ${count} file(s)`, + invalidSkillIdArray: "Invalid skill_id array", }, // Page titles (document title) diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 95c5dea6..b9e79878 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -47,6 +47,7 @@ export interface Translations { exportAsJSON: string; exportSuccess: string; removeAttachment: string; + reference: string; }; // Welcome @@ -99,6 +100,13 @@ export interface Translations { followupConfirmDescription: string; followupConfirmAppend: string; followupConfirmReplace: string; + submit: string; + submitting: string; + stop: string; + addReference: string; + referenceSourceArtifact: string; + referenceSourceUpload: string; + maxReferencesReached: string; suggestions: { suggestion: string; prompt: string; @@ -179,6 +187,80 @@ export interface Translations { // Chats chats: { searchChats: string; + scrollToBottom: string; + }; + + // Workspace Chat Page + chatPage: { + defaultSlogan: string; + missingThreadIdForCreate: string; + createSessionFailed: string; + conversationFinished: string; + missingThreadIdForSend: string; + viewArtifactsTooltip: string; + noArtifactSelectedTitle: string; + noArtifactSelectedDescription: string; + exitDialogTitle: string; + exitDialogDescription: string; + exitDialogConfirm: string; + selectedSkillLoadFailed: string; + unknownErrorRetry: string; + }; + + messageListItem: { + materializing: string; + importAsSkillDir: string; + materializeSuccess: (files: number, directories: number) => string; + parseFailed: string; + materializeFailed: (message: string) => string; + }; + + artifactPreview: { + pdfPreviewFailed: string; + unsupportedType: string; + docxPreviewFailed: string; + excelPreviewFailed: string; + switchSheetFailed: string; + excelGridPreviewFailed: string; + pptxDownloadHint: string; + openInNewTab: string; + clickToDownload: string; + pageCountLabel: (fileName: string, pageCount: number) => string; + zoomIn: string; + zoomOut: string; + showArtifactsTooltip: string; + }; + + workspaceHeader: { + sidebarTitle: string; + }; + + models: { + updating: string; + apiUnavailable: string; + }; + + threads: { + streamError: string; + invalidThreadId: string; + staleReferencesRemoved: string; + uploadFailed: string; + uploadPrepareFailed: (count: number) => string; + threadNotReadyForUpload: string; + }; + + skills: { + loadFailed: string; + missingThreadId: string; + invalidSkillId: string; + loading: (title: string) => string; + loadFailedWithTitle: (title: string) => string; + loadSuccessWithTitle: (title: string) => string; + loadErrorWithTitle: (title: string) => string; + unknownError: string; + networkRequestFailed: string; + createdFiles: (count: number) => string; + invalidSkillIdArray: string; }; // Page titles (document title) diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index aaa60aae..20015508 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -54,6 +54,7 @@ export const zhCN: Translations = { exportAsJSON: "导出为 JSON", exportSuccess: "对话已导出", removeAttachment: "移除附件", + reference: "引用", }, // Welcome @@ -112,6 +113,13 @@ export const zhCN: Translations = { followupConfirmDescription: "当前输入框已有内容,选择发送方式。", followupConfirmAppend: "追加并发送", followupConfirmReplace: "替换并发送", + submit: "发送", + submitting: "生成中...", + stop: "停止", + addReference: "添加引用", + referenceSourceArtifact: "生成文件", + referenceSourceUpload: "上传附件", + maxReferencesReached: "单条消息最多引用 10 个文件", suggestions: [ { suggestion: "自媒体文案", @@ -237,6 +245,83 @@ export const zhCN: Translations = { // Chats chats: { searchChats: "搜索对话", + scrollToBottom: "滚动到底部", + }, + + // Workspace Chat Page + chatPage: { + defaultSlogan: "来,一起学习工作吧", + missingThreadIdForCreate: "缺少 thread_id,无法创建会话", + createSessionFailed: "会话创建失败,请稍后重试", + conversationFinished: "对话已完成", + missingThreadIdForSend: "缺少 thread_id,无法发送消息", + viewArtifactsTooltip: "点击可查看生成的文件结果", + noArtifactSelectedTitle: "未选择生成文件", + noArtifactSelectedDescription: "请选择一个生成文件以查看详情", + exitDialogTitle: "提示", + exitDialogDescription: "历史记录每七天自动删除,现在将返回欢迎页,是否继续?", + exitDialogConfirm: "确定", + selectedSkillLoadFailed: "技能加载失败", + unknownErrorRetry: "发生了未知错误,请稍后重试。", + }, + + messageListItem: { + materializing: "解析中...", + importAsSkillDir: "一键导入为 Skill 目录", + materializeSuccess: (files: number, directories: number) => + `已创建 ${files} 个文件 / ${directories} 个目录`, + parseFailed: "解析失败", + materializeFailed: (message: string) => `失败: ${message}`, + }, + + artifactPreview: { + pdfPreviewFailed: "无法预览该 PDF 文件,请下载后查看。", + unsupportedType: "该文件类型暂不支持在自定义预览器中查看。", + docxPreviewFailed: "无法预览该 DOCX 文件。", + excelPreviewFailed: "无法预览该 Excel 文件。", + switchSheetFailed: "切换工作表失败。", + excelGridPreviewFailed: "无法渲染 Excel 网格预览。", + pptxDownloadHint: "请下载 ppt 文件以获得最佳效果", + openInNewTab: "在新标签页打开", + clickToDownload: "点击下载", + pageCountLabel: (fileName: string, pageCount: number) => + `${fileName} · 共 ${pageCount} 页`, + zoomIn: "放大", + zoomOut: "缩小", + showArtifactsTooltip: "查看当前对话的生成文件", + }, + + workspaceHeader: { + sidebarTitle: "XClaw侧边栏", + }, + + models: { + updating: "系统正在更新,请稍候……", + apiUnavailable: "模型接口不可用,请检查后端路由或服务状态。", + }, + + threads: { + streamError: "出现了某些错误。", + invalidThreadId: "线程 ID 无效(new),请刷新后重试。", + staleReferencesRemoved: "部分引用文件已失效,已自动移除并继续发送。", + uploadFailed: "文件上传失败。", + uploadPrepareFailed: (count: number) => + `准备上传附件失败(${count} 个),请重试。`, + threadNotReadyForUpload: "当前线程尚未就绪,无法上传文件。", + }, + + skills: { + loadFailed: "技能加载失败", + missingThreadId: "缺少 thread_id,无法初始化技能", + invalidSkillId: "无效的 skill_id", + loading: (title: string) => `正在加载技能「${title}」...`, + loadFailedWithTitle: (title: string) => `技能「${title}」加载失败`, + loadSuccessWithTitle: (title: string) => `技能「${title}」加载成功`, + loadErrorWithTitle: (title: string) => `技能「${title}」加载出错`, + unknownError: "未知错误", + networkRequestFailed: "网络请求失败", + createdFiles: (count: number) => `已创建 ${count} 个文件`, + invalidSkillIdArray: "非法 skill_id 数组", }, // Page titles (document title) diff --git a/frontend/src/core/models/hooks.ts b/frontend/src/core/models/hooks.ts index e6ec7f2e..3aa52215 100644 --- a/frontend/src/core/models/hooks.ts +++ b/frontend/src/core/models/hooks.ts @@ -2,12 +2,15 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect } from "react"; import { toast } from "sonner"; +import { useI18n } from "../i18n/hooks"; + import { loadModels } from "./api"; import type { Model } from "./types"; const MODELS_UPDATING_TOAST_ID = "models-server-updating"; export function useModels({ enabled = true }: { enabled?: boolean } = {}) { + const { t } = useI18n(); const { data, isLoading, error, failureReason } = useQuery({ queryKey: ["models"], queryFn: () => loadModels(), @@ -31,7 +34,7 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) { ); if (serverError) { - toast.loading("系统正在更新,请稍候……", { + toast.loading(t.models.updating, { id: MODELS_UPDATING_TOAST_ID, }); return; @@ -42,9 +45,9 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) { useEffect(() => { if (error?.message.includes("HTTP error: 4")) { - toast.error("模型接口不可用,请检查后端路由或服务状态。"); + toast.error(t.models.apiUnavailable); } - }, [error]); + }, [error, t.models.apiUnavailable]); return { models: data ?? [], isLoading, error }; } diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 5a1bae72..a4b763a6 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -50,7 +50,6 @@ export type LegacyThreadStreamOptions = { }; const STREAM_ERROR_FALLBACK_MESSAGE = "Request failed."; -const STREAM_ERROR_TOAST_MESSAGE = "出现了某些错误。"; const STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS = 2000; const STREAM_CANCEL_PATTERNS = [ /\bcancellederror\b/i, @@ -276,8 +275,8 @@ export function useThreadStream({ lastErrorToastRef.current = { message, timestamp: now }; console.error("[useThreadStream] conversation stream error:", error); console.error("[useThreadStream] parsed error message:", message); - toast.error(STREAM_ERROR_TOAST_MESSAGE); - }, []); + toast.error(t.threads.streamError); + }, [t.threads.streamError]); const handleStreamStart = useCallback( (_threadId: string) => { @@ -407,7 +406,7 @@ export function useThreadStream({ normalizeThreadId(threadIdRef.current) ?? undefined; if (resolvedThreadId === "new") { - toast.error("Invalid thread id 'new'. Please refresh and retry."); + toast.error(t.threads.invalidThreadId); sendInFlightRef.current = false; return; } @@ -503,12 +502,12 @@ export function useThreadStream({ if (failedConversions > 0) { throw new Error( - `Failed to prepare ${failedConversions} attachment(s) for upload. Please retry.`, + t.threads.uploadPrepareFailed(failedConversions), ); } if (!resolvedThreadId) { - throw new Error("Thread is not ready for file upload."); + throw new Error(t.threads.threadNotReadyForUpload); } if (files.length > 0) { @@ -545,9 +544,7 @@ export function useThreadStream({ } catch (error) { console.error("Failed to upload files:", error); const errorMessage = - error instanceof Error - ? error.message - : "Failed to upload files."; + error instanceof Error ? error.message : t.threads.uploadFailed; toast.error(errorMessage); setOptimisticMessages([]); throw error; @@ -563,7 +560,7 @@ export function useThreadStream({ normalizedReferences, ); if (staleCount > 0) { - toast.error("部分引用文件已失效,已自动移除并继续发送。"); + toast.error(t.threads.staleReferencesRemoved); } await thread.submit( @@ -621,6 +618,11 @@ export function useThreadStream({ thread, _handleOnStart, t.uploads.uploadingFiles, + t.threads.invalidThreadId, + t.threads.uploadPrepareFailed, + t.threads.threadNotReadyForUpload, + t.threads.uploadFailed, + t.threads.staleReferencesRemoved, context, queryClient, apiClient, @@ -659,12 +661,13 @@ export function useSubmitThread({ uploadTarget?: UploadTarget; afterSubmit?: () => void; }) { + const { t } = useI18n(); const queryClient = useQueryClient(); const apiClient = getAPIClient(); const callback = useCallback( async (message: PromptInputMessage) => { if (threadId === "new") { - toast.error("Invalid thread id 'new'. Please refresh and retry."); + toast.error(t.threads.invalidThreadId); return; } const text = message.text.trim(); @@ -730,7 +733,7 @@ export function useSubmitThread({ normalizedReferences, ); if (staleCount > 0) { - toast.error("部分引用文件已失效,已自动移除并继续发送。"); + toast.error(t.threads.staleReferencesRemoved); } await thread.submit( @@ -769,6 +772,8 @@ export function useSubmitThread({ }, [ thread, + t.threads.invalidThreadId, + t.threads.staleReferencesRemoved, createNewSession, threadId, threadContext, diff --git a/frontend/src/hooks/use-iframe-skill.ts b/frontend/src/hooks/use-iframe-skill.ts index c5c552c3..00b72379 100644 --- a/frontend/src/hooks/use-iframe-skill.ts +++ b/frontend/src/hooks/use-iframe-skill.ts @@ -10,6 +10,7 @@ import { type SelectedSkillPayloadItem, sendToParent, } from "@/core/iframe-messages"; +import { useI18n } from "@/core/i18n/hooks"; import { bootstrapRemoteSkill } from "@/core/skills/api"; // Skill 数据类型 @@ -79,6 +80,7 @@ interface UseIframeSkillOptions { export function useIframeSkill( options?: UseIframeSkillOptions, ): UseIframeSkillReturn { + const { t } = useI18n(); const router = useRouter(); const searchParams = useSearchParams(); const threadIdFromQuery = searchParams.get("thread_id"); @@ -251,8 +253,8 @@ export function useIframeSkill( title: string; }) => { if (!threadId) { - toast.error("技能加载失败", { - description: "缺少 thread_id,无法初始化技能", + toast.error(t.skills.loadFailed, { + description: t.skills.missingThreadId, }); return false; } @@ -266,8 +268,8 @@ export function useIframeSkill( ); if (content_ids.length === 0) { - toast.error("技能加载失败", { - description: "无效的 skill_id", + toast.error(t.skills.loadFailed, { + description: t.skills.invalidSkillId, }); return false; } @@ -278,7 +280,7 @@ export function useIframeSkill( const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; setIsBootstrapping(true); - toast.loading(`正在加载技能「${title}」...`, { + toast.loading(t.skills.loading(title), { id: "suggest-skill-bootstrap", }); @@ -298,8 +300,8 @@ export function useIframeSkill( String(item.id).trim(), ); removeFailedSkills(failedIds); - toast.error(`技能「${title}」加载失败`, { - description: result.message || "未知错误", + toast.error(t.skills.loadFailedWithTitle(title), { + description: result.message || t.skills.unknownError, }); return false; } @@ -312,9 +314,8 @@ export function useIframeSkill( setSelectedSkill(normalizedSkills[0] ?? null); setSelectedSkills(normalizedSkills); - toast.success(`技能「${title}」加载成功`, { - description: - result.message || `已创建 ${result.created_files} 个文件`, + toast.success(t.skills.loadSuccessWithTitle(title), { + description: result.message || t.skills.createdFiles(result.created_files), }); return true; @@ -322,8 +323,9 @@ export function useIframeSkill( const failedIds = selectedSkills.map((item) => String(item.id).trim()); removeFailedSkills(failedIds); toast.dismiss("suggest-skill-bootstrap"); - const message = error instanceof Error ? error.message : "网络请求失败"; - toast.error(`技能「${title}」加载失败`, { + const message = + error instanceof Error ? error.message : t.skills.networkRequestFailed; + toast.error(t.skills.loadFailedWithTitle(title), { description: message, }); return false; @@ -331,7 +333,7 @@ export function useIframeSkill( setIsBootstrapping(false); } }, - [removeFailedSkills, searchParams, sendSelectSkill, threadId], + [removeFailedSkills, searchParams, sendSelectSkill, t.skills, threadId], ); // 打开 skill 选择对话框 diff --git a/frontend/src/hooks/use-selected-skill-listener.ts b/frontend/src/hooks/use-selected-skill-listener.ts index 126007a1..462464a1 100644 --- a/frontend/src/hooks/use-selected-skill-listener.ts +++ b/frontend/src/hooks/use-selected-skill-listener.ts @@ -7,6 +7,7 @@ import { isSelectedSkillsMessage, type SelectedSkillPayloadItem, } from "@/core/iframe-messages"; +import { useI18n } from "@/core/i18n/hooks"; import { bootstrapRemoteSkill } from "@/core/skills/api"; /** 技能基础数据 */ @@ -46,6 +47,7 @@ interface UseSelectedSkillListenerReturn { export function useSelectedSkillListener({ threadId, }: UseSelectedSkillListenerOptions): UseSelectedSkillListenerReturn { + const { t } = useI18n(); const searchParams = useSearchParams(); const [selectedSkill, setSelectedSkill] = useState(null); const [skillError, setSkillError] = useState(null); @@ -67,8 +69,8 @@ export function useSelectedSkillListener({ if (contentIds.length === 0) { console.warn("[useSelectedSkillListener] 忽略非法 skill ids", skills); setSkillError({ - title: `技能「${title}」加载失败`, - message: "非法 skill_id 数组", + title: t.skills.loadFailedWithTitle(title), + message: t.skills.invalidSkillIdArray, }); return; } @@ -87,7 +89,7 @@ export function useSelectedSkillListener({ `[useSelectedSkillListener] 开始初始化技能: ${title} (${contentIds.join(",")})`, ); setIsBootstrapping(true); - toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" }); + toast.loading(t.skills.loading(title), { id: "skill-bootstrap" }); try { const result = await bootstrapRemoteSkill({ @@ -102,26 +104,26 @@ export function useSelectedSkillListener({ if (result.success) { skillBootstrappedKeyRef.current = initKey; - toast.success(`技能「${title}」加载成功`, { - description: - result.message || `已创建 ${result.created_files} 个文件`, + toast.success(t.skills.loadSuccessWithTitle(title), { + description: result.message || t.skills.createdFiles(result.created_files), duration: 4000, }); } else { setSkillError({ - title: `技能「${title}」加载失败`, - message: result.message || "未知错误", + title: t.skills.loadFailedWithTitle(title), + message: result.message || t.skills.unknownError, }); } } catch (err) { toast.dismiss("skill-bootstrap"); - const message = err instanceof Error ? err.message : "网络请求失败"; - setSkillError({ title: `技能「${title}」加载出错`, message }); + const message = + err instanceof Error ? err.message : t.skills.networkRequestFailed; + setSkillError({ title: t.skills.loadErrorWithTitle(title), message }); } finally { setIsBootstrapping(false); } }, - [threadId, searchParams], + [threadId, searchParams, t.skills], ); // 1. URL 初始化集成