refactor: 不使用硬编码,而全部使用i18n

This commit is contained in:
肖应宇 2026-04-16 16:00:41 +08:00
parent 3d5006af48
commit 9758ae8a3a
17 changed files with 441 additions and 112 deletions

View File

@ -94,7 +94,7 @@ export default function ChatPage() {
const currentSlogan = motivationSlogans[ const currentSlogan = motivationSlogans[
sloganIndex % motivationSlogans.length sloganIndex % motivationSlogans.length
] ?? { ] ?? {
text: "来,一起学习工作吧", text: t.chatPage.defaultSlogan,
color: "#333333", color: "#333333",
}; };
const tickerCharacterList = useMemo(() => { const tickerCharacterList = useMemo(() => {
@ -133,7 +133,7 @@ export default function ChatPage() {
if (!safeThreadId) { if (!safeThreadId) {
if (!warnedMissingThreadIdRef.current) { if (!warnedMissingThreadIdRef.current) {
warnedMissingThreadIdRef.current = true; warnedMissingThreadIdRef.current = true;
toast.error("缺少 thread_id无法创建会话"); toast.error(t.chatPage.missingThreadIdForCreate);
} }
return; return;
} }
@ -156,9 +156,15 @@ export default function ChatPage() {
}) })
.catch(() => { .catch(() => {
initializedThreadRef.current = null; initializedThreadRef.current = null;
toast.error("会话创建失败,请稍后重试"); toast.error(t.chatPage.createSessionFailed);
}); });
}, [apiClient, isNewThread, safeThreadId]); }, [
apiClient,
isNewThread,
safeThreadId,
t.chatPage.createSessionFailed,
t.chatPage.missingThreadIdForCreate,
]);
// 监听宿主页 selectedSkill 消息 // 监听宿主页 selectedSkill 消息
const { const {
@ -183,7 +189,7 @@ export default function ChatPage() {
}, },
onFinish: (state) => { onFinish: (state) => {
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished"; let body = t.chatPage.conversationFinished;
const lastMessage = state.messages.at(-1); const lastMessage = state.messages.at(-1);
if (lastMessage) { if (lastMessage) {
const textContent = textOfMessage(lastMessage); const textContent = textOfMessage(lastMessage);
@ -235,12 +241,13 @@ export default function ChatPage() {
? thread.values.title ? thread.values.title
: t.pages.untitled; : t.pages.untitled;
if (thread.isThreadLoading) { if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`; document.title = `${t.common.loading} - ${t.pages.appName}`;
} else { } else {
document.title = `${pageTitle} - ${t.pages.appName}`; document.title = `${pageTitle} - ${t.pages.appName}`;
} }
}, [ }, [
isNewThread, isNewThread,
t.common.loading,
t.pages.newChat, t.pages.newChat,
t.pages.untitled, t.pages.untitled,
t.pages.appName, t.pages.appName,
@ -283,7 +290,7 @@ export default function ChatPage() {
return; return;
} }
if (isNewThread && !safeThreadId) { if (isNewThread && !safeThreadId) {
toast.error("缺少 thread_id无法发送消息"); toast.error(t.chatPage.missingThreadIdForSend);
return; return;
} }
setHasSubmitted(true); setHasSubmitted(true);
@ -299,6 +306,7 @@ export default function ChatPage() {
safeThreadId, safeThreadId,
sendMessage, sendMessage,
showWelcomeStyle, showWelcomeStyle,
t.chatPage.missingThreadIdForSend,
], ],
); );
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
@ -407,7 +415,7 @@ export default function ChatPage() {
/> */} /> */}
{artifacts?.length > 0 && !artifactsOpen && ( {artifacts?.length > 0 && !artifactsOpen && (
<Tooltip content="点击可查看生成的文件结果"> <Tooltip content={t.chatPage.viewArtifactsTooltip}>
<Button <Button
data-testid="artifacts-open-button" data-testid="artifacts-open-button"
className="text-[#150033] hover:text-[#150033]/80" className="text-[#150033] hover:text-[#150033]/80"
@ -487,8 +495,8 @@ export default function ChatPage() {
{thread.values.artifacts?.length === 0 ? ( {thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState <ConversationEmptyState
icon={<FilesIcon />} icon={<FilesIcon />}
title="No artifact selected" title={t.chatPage.noArtifactSelectedTitle}
description="Select an artifact to view its details" description={t.chatPage.noArtifactSelectedDescription}
/> />
) : ( ) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center"> <div className="flex size-full max-w-(--container-width-sm) flex-col justify-center">
@ -594,10 +602,10 @@ export default function ChatPage() {
<DevDialog open={showExitDialog} onOpenChange={setShowExitDialog}> <DevDialog open={showExitDialog} onOpenChange={setShowExitDialog}>
<DevDialogContent> <DevDialogContent>
<DevDialogHeader> <DevDialogHeader>
<DevDialogTitle></DevDialogTitle> <DevDialogTitle>{t.chatPage.exitDialogTitle}</DevDialogTitle>
</DevDialogHeader> </DevDialogHeader>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{t.chatPage.exitDialogDescription}
</p> </p>
<DevDialogFooter> <DevDialogFooter>
<Button <Button
@ -605,7 +613,7 @@ export default function ChatPage() {
variant="ghost" variant="ghost"
onClick={() => setShowExitDialog(false)} onClick={() => setShowExitDialog(false)}
> >
{t.common.cancel}
</Button> </Button>
<Button <Button
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white" className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
@ -631,7 +639,7 @@ export default function ChatPage() {
); );
}} }}
> >
{t.chatPage.exitDialogConfirm}
</Button> </Button>
</DevDialogFooter> </DevDialogFooter>
</DevDialogContent> </DevDialogContent>
@ -647,11 +655,11 @@ export default function ChatPage() {
<DevDialogContent> <DevDialogContent>
<DevDialogHeader> <DevDialogHeader>
<DevDialogTitle> <DevDialogTitle>
{selectedSkillError?.title ?? "技能加载失败"} {selectedSkillError?.title ?? t.chatPage.selectedSkillLoadFailed}
</DevDialogTitle> </DevDialogTitle>
</DevDialogHeader> </DevDialogHeader>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{selectedSkillError?.message ?? "发生了未知错误,请稍后重试。"} {selectedSkillError?.message ?? t.chatPage.unknownErrorRetry}
</p> </p>
<DevDialogFooter singleColumn> <DevDialogFooter singleColumn>
<Button <Button
@ -659,7 +667,7 @@ export default function ChatPage() {
variant="ghost" variant="ghost"
onClick={clearSelectedSkillError} onClick={clearSelectedSkillError}
> >
{t.common.close}
</Button> </Button>
</DevDialogFooter> </DevDialogFooter>
</DevDialogContent> </DevDialogContent>

View File

@ -1153,19 +1153,19 @@ export const PromptInputSubmit = ({
let Icon = <ArrowUpIcon className="size-4" />; let Icon = <ArrowUpIcon className="size-4" />;
let text: string = "发送"; let text: string = t.inputBox.submit;
if (status === "submitted") { if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />; Icon = <Loader2Icon className="size-4 animate-spin" />;
text = "生成中..."; text = t.inputBox.submitting;
} else if (status === "streaming") { } else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />; Icon = <SquareIcon className="size-4" />;
text = "停止"; text = t.inputBox.stop;
} else if (status === "error") { } else if (status === "error") {
// 没有报错状态先用error状态代替 // 没有报错状态先用error状态代替
Icon = <XIcon className="size-4" />; Icon = <XIcon className="size-4" />;
// MARK: 这里后端没有返回错误信息,先写死一个文本 // MARK: 这里后端没有返回错误信息,先写死一个文本
text = "发送"; text = t.inputBox.submit;
} }
return ( return (

View File

@ -210,8 +210,18 @@ export function ArtifactFileDetail({
artifactUrl, artifactUrl,
fileName, fileName,
kind: artifactPreviewKind, 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. // Native PDF iframe rendering is intentionally disabled; PDFs are rendered via pdf.js.
const artifactViewerSrc = useMemo(() => { const artifactViewerSrc = useMemo(() => {
return undefined; return undefined;
@ -954,6 +964,7 @@ function ArtifactPdfPreview({
artifactUrl: string; artifactUrl: string;
fileName: string; fileName: string;
}) { }) {
const { t } = useI18n();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pageCount, setPageCount] = useState(0); const [pageCount, setPageCount] = useState(0);
@ -1033,7 +1044,7 @@ function ArtifactPdfPreview({
} catch (err) { } catch (err) {
console.error("Failed to render pdf preview:", err); console.error("Failed to render pdf preview:", err);
if (!disposed) { if (!disposed) {
setError("无法预览该 PDF 文件,请下载后查看。"); setError(t.artifactPreview.pdfPreviewFailed);
} }
} finally { } finally {
if (!disposed) { if (!disposed) {
@ -1047,7 +1058,7 @@ function ArtifactPdfPreview({
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [artifactUrl]); }, [artifactUrl, t.artifactPreview.pdfPreviewFailed]);
if (error) { if (error) {
return ( return (
@ -1061,7 +1072,7 @@ function ArtifactPdfPreview({
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{t.artifactPreview.openInNewTab}
</a> </a>
</div> </div>
</div> </div>
@ -1071,7 +1082,9 @@ function ArtifactPdfPreview({
return ( return (
<div className={cn("relative overflow-auto bg-[#f8f9fb] p-4", className)}> <div className={cn("relative overflow-auto bg-[#f8f9fb] p-4", className)}>
<div className="mb-3 text-center text-xs text-[#667085]"> <div className="mb-3 text-center text-xs text-[#667085]">
{pageCount > 0 ? `${fileName} · ${pageCount} page(s)` : fileName} {pageCount > 0
? t.artifactPreview.pageCountLabel(fileName, pageCount)
: fileName}
</div> </div>
<div ref={containerRef} /> <div ref={containerRef} />
{isLoading && ( {isLoading && (
@ -1094,6 +1107,7 @@ function ArtifactOfficePreview({
artifactUrl: string; artifactUrl: string;
fileName: string; fileName: string;
}) { }) {
const { t } = useI18n();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]); const [sheetNames, setSheetNames] = useState<string[]>([]);
@ -1138,7 +1152,7 @@ function ArtifactOfficePreview({
} catch (err) { } catch (err) {
console.error("Failed to render docx preview:", err); console.error("Failed to render docx preview:", err);
if (!disposed) { if (!disposed) {
setError("无法预览该 DOCX 文件。"); setError(t.artifactPreview.docxPreviewFailed);
} }
} finally { } finally {
if (!disposed) { if (!disposed) {
@ -1151,7 +1165,7 @@ function ArtifactOfficePreview({
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [artifactUrl, canRenderDocx]); }, [artifactUrl, canRenderDocx, t.artifactPreview.docxPreviewFailed]);
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
@ -1186,7 +1200,7 @@ function ArtifactOfficePreview({
} catch (err) { } catch (err) {
console.error("Failed to render xlsx preview:", err); console.error("Failed to render xlsx preview:", err);
if (!disposed) { if (!disposed) {
setError("无法预览该 Excel 文件。"); setError(t.artifactPreview.excelPreviewFailed);
} }
} finally { } finally {
if (!disposed) { if (!disposed) {
@ -1199,7 +1213,7 @@ function ArtifactOfficePreview({
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [artifactUrl, canRenderXlsx]); }, [artifactUrl, canRenderXlsx, t.artifactPreview.excelPreviewFailed]);
useEffect(() => { useEffect(() => {
if (!canRenderXlsx || !activeSheet || !workbookRef.current) { if (!canRenderXlsx || !activeSheet || !workbookRef.current) {
@ -1213,9 +1227,9 @@ function ArtifactOfficePreview({
setXlsxRows(rows); setXlsxRows(rows);
} catch (err) { } catch (err) {
console.error("Failed to switch xlsx sheet:", err); console.error("Failed to switch xlsx sheet:", err);
setError("切换工作表失败。"); setError(t.artifactPreview.switchSheetFailed);
} }
}, [activeSheet, canRenderXlsx]); }, [activeSheet, canRenderXlsx, t.artifactPreview.switchSheetFailed]);
useEffect(() => { useEffect(() => {
if (!canRenderXlsx || !xlsxGridContainerRef.current) { if (!canRenderXlsx || !xlsxGridContainerRef.current) {
@ -1247,7 +1261,7 @@ function ArtifactOfficePreview({
} catch (err) { } catch (err) {
console.error("Failed to render RevoGrid preview:", err); console.error("Failed to render RevoGrid preview:", err);
if (!disposed) { if (!disposed) {
setError("无法渲染 Excel 网格预览。"); setError(t.artifactPreview.excelGridPreviewFailed);
} }
} }
} }
@ -1257,14 +1271,19 @@ function ArtifactOfficePreview({
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [canRenderXlsx, xlsxColumns, xlsxRows]); }, [
canRenderXlsx,
xlsxColumns,
xlsxRows,
t.artifactPreview.excelGridPreviewFailed,
]);
useEffect(() => { useEffect(() => {
if (!canRenderPptx) { if (!canRenderPptx) {
return; return;
} }
setIsLoading(false); setIsLoading(false);
setError("请下载ppt文件以获得最佳效果"); setError(t.artifactPreview.pptxDownloadHint);
}, [canRenderPptx]); }, [canRenderPptx, t.artifactPreview.pptxDownloadHint]);
return ( return (
<div className={cn("relative h-full overflow-hidden bg-white", className)}> <div className={cn("relative h-full overflow-hidden bg-white", className)}>
@ -1328,6 +1347,7 @@ function ArtifactPreviewFallback({
fileName: string; fileName: string;
artifactUrl: string; artifactUrl: string;
}) { }) {
const { t } = useI18n();
return ( return (
<div className="absolute inset-0 z-20 grid place-content-center bg-white p-6 text-center"> <div className="absolute inset-0 z-20 grid place-content-center bg-white p-6 text-center">
<p className="text-foreground mb-2 text-sm font-medium">{fileName}</p> <p className="text-foreground mb-2 text-sm font-medium">{fileName}</p>
@ -1338,7 +1358,7 @@ function ArtifactPreviewFallback({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{t.artifactPreview.clickToDownload}
</a> </a>
</div> </div>
); );
@ -1459,13 +1479,22 @@ function buildArtifactViewerSrcDoc({
artifactUrl, artifactUrl,
fileName, fileName,
kind, kind,
pdfPreviewMessage,
unsupportedTypeMessage,
openInNewTabLabel,
}: { }: {
artifactUrl: string; artifactUrl: string;
fileName: string; fileName: string;
kind: ArtifactPreviewKind; kind: ArtifactPreviewKind;
pdfPreviewMessage: string;
unsupportedTypeMessage: string;
openInNewTabLabel: string;
}) { }) {
const safeUrl = escapeHtml(artifactUrl); const safeUrl = escapeHtml(artifactUrl);
const safeName = escapeHtml(fileName); const safeName = escapeHtml(fileName);
const safePdfPreviewMessage = escapeHtml(pdfPreviewMessage);
const safeUnsupportedTypeMessage = escapeHtml(unsupportedTypeMessage);
const safeOpenInNewTabLabel = escapeHtml(openInNewTabLabel);
const content = (() => { const content = (() => {
if (kind === "image") { if (kind === "image") {
@ -1480,8 +1509,8 @@ function buildArtifactViewerSrcDoc({
if (kind === "pdf") { if (kind === "pdf") {
return `<div class="fallback"> return `<div class="fallback">
<p class="title">${safeName}</p> <p class="title">${safeName}</p>
<p class="desc">PDF preview is temporarily disabled. Please download the file to view it.</p> <p class="desc">${safePdfPreviewMessage}</p>
<a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open in new tab</a> <a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeOpenInNewTabLabel}</a>
</div>`; </div>`;
} }
if (kind === "html") { if (kind === "html") {
@ -1489,8 +1518,8 @@ function buildArtifactViewerSrcDoc({
} }
return `<div class="fallback"> return `<div class="fallback">
<p class="title">${safeName}</p> <p class="title">${safeName}</p>
<p class="desc">This file type is not previewable in the custom viewer.</p> <p class="desc">${safeUnsupportedTypeMessage}</p>
<a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open in new tab</a> <a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeOpenInNewTabLabel}</a>
</div>`; </div>`;
})(); })();
@ -1622,6 +1651,7 @@ export const ArtifactZoomSelector = ({
className, className,
...props ...props
}: ArtifactZoomSelectorProps) => { }: ArtifactZoomSelectorProps) => {
const { t } = useI18n();
const handleZoomIn = () => { const handleZoomIn = () => {
const currentIndex = ZOOM_LEVELS.indexOf(value); const currentIndex = ZOOM_LEVELS.indexOf(value);
const nextValue = ZOOM_LEVELS[currentIndex + 1]; const nextValue = ZOOM_LEVELS[currentIndex + 1];
@ -1660,7 +1690,7 @@ export const ArtifactZoomSelector = ({
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent", "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground", "dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground",
)} )}
aria-label="放大" aria-label={t.artifactPreview.zoomIn}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -1700,7 +1730,7 @@ export const ArtifactZoomSelector = ({
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent", "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground", "dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground",
)} )}
aria-label="缩小" aria-label={t.artifactPreview.zoomOut}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@ -161,7 +161,7 @@ export function ArtifactFileList({
}); });
}} }}
> >
{t.common.reference}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>

View File

@ -14,7 +14,7 @@ export const ArtifactTrigger = () => {
return null; return null;
} }
return ( return (
<Tooltip content="Show artifacts of this conversation"> <Tooltip content={t.artifactPreview.showArtifactsTooltip}>
<Button <Button
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
variant="ghost" variant="ghost"

View File

@ -10,6 +10,7 @@ import {
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -27,6 +28,7 @@ const ChatBox: React.FC<{
children: React.ReactNode; children: React.ReactNode;
threadId: string | undefined; threadId: string | undefined;
}> = ({ children, threadId }) => { }> = ({ children, threadId }) => {
const { t } = useI18n();
const { thread } = useThread(); const { thread } = useThread();
const pathname = usePathname(); const pathname = usePathname();
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);
@ -152,13 +154,13 @@ const ChatBox: React.FC<{
{thread.values.artifacts?.length === 0 ? ( {thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState <ConversationEmptyState
icon={<FilesIcon />} icon={<FilesIcon />}
title="No artifact selected" title={t.chatPage.noArtifactSelectedTitle}
description="Select an artifact to view its details" description={t.chatPage.noArtifactSelectedDescription}
/> />
) : ( ) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8"> <div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0"> <header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2> <h2 className="text-lg font-medium">{t.common.artifacts}</h2>
</header> </header>
<main className="min-h-0 grow"> <main className="min-h-0 grow">
<ArtifactFileList <ArtifactFileList

View File

@ -99,11 +99,6 @@ import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.
const MAX_REFERENCES_PER_MESSAGE = 10; const MAX_REFERENCES_PER_MESSAGE = 10;
const REFERENCE_SOURCE_LABELS = {
artifact: "生成文件",
upload: "上传附件",
} as const;
type MentionCandidate = { type MentionCandidate = {
key: string; key: string;
filename: string; filename: string;
@ -200,6 +195,13 @@ export function InputBox({
onStop?: () => void; onStop?: () => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const referenceSourceLabels = useMemo(
() => ({
artifact: t.inputBox.referenceSourceArtifact,
upload: t.inputBox.referenceSourceUpload,
}),
[t],
);
const { thread } = useThread(); const { thread } = useThread();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps }); const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
@ -280,7 +282,7 @@ export function InputBox({
pathTail: getPathTail(path), pathTail: getPathTail(path),
ref_source: "artifact" as const, ref_source: "artifact" as const,
ref_kind: "mention" as const, ref_kind: "mention" as const,
typeLabel: REFERENCE_SOURCE_LABELS.artifact, typeLabel: referenceSourceLabels.artifact,
isImage: isImageFilename(filename), isImage: isImageFilename(filename),
previewUrl: threadId previewUrl: threadId
? urlOfArtifact({ ? urlOfArtifact({
@ -299,7 +301,7 @@ export function InputBox({
pathTail: getPathTail(file.virtual_path), pathTail: getPathTail(file.virtual_path),
ref_source: "upload" as const, ref_source: "upload" as const,
ref_kind: "mention" as const, ref_kind: "mention" as const,
typeLabel: REFERENCE_SOURCE_LABELS.upload, typeLabel: referenceSourceLabels.upload,
isImage: isImageFilename(file.filename), isImage: isImageFilename(file.filename),
previewUrl: file.artifact_url, previewUrl: file.artifact_url,
})) ?? []; })) ?? [];
@ -309,7 +311,13 @@ export function InputBox({
deduped.set(candidate.key, candidate); deduped.set(candidate.key, candidate);
}); });
return [...deduped.values()]; return [...deduped.values()];
}, [thread.values.artifacts, uploadedFilesData?.files, threadId]); }, [
referenceSourceLabels.artifact,
referenceSourceLabels.upload,
thread.values.artifacts,
uploadedFilesData?.files,
threadId,
]);
const filteredMentionCandidates = useMemo(() => { const filteredMentionCandidates = useMemo(() => {
const query = mentionQuery.trim().toLowerCase(); const query = mentionQuery.trim().toLowerCase();
@ -383,12 +391,12 @@ export function InputBox({
return prev; return prev;
} }
if (prev.length >= MAX_REFERENCES_PER_MESSAGE) { if (prev.length >= MAX_REFERENCES_PER_MESSAGE) {
toast.error("单条消息最多引用 10 个文件"); toast.error(t.inputBox.maxReferencesReached);
return prev; return prev;
} }
return prev.concat(reference); return prev.concat(reference);
}); });
}, []); }, [t.inputBox.maxReferencesReached]);
const selectMentionCandidate = useCallback( const selectMentionCandidate = useCallback(
(candidate: MentionCandidate) => { (candidate: MentionCandidate) => {
@ -687,7 +695,7 @@ export function InputBox({
}} }}
> >
<DropdownMenuLabel className="p-0 text-[14px] text-[#333333]"> <DropdownMenuLabel className="p-0 text-[14px] text-[#333333]">
{t.inputBox.addReference}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" /> <DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" />
<DropdownMenuGroup className="flex pt-[20px] px-0 max-h-[480px] flex-col gap-[10px] overflow-y-auto"> <DropdownMenuGroup className="flex pt-[20px] px-0 max-h-[480px] flex-col gap-[10px] overflow-y-auto">
@ -842,7 +850,7 @@ export function InputBox({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{followupsLoading ? ( {followupsLoading ? (
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm"> <div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm">
... {t.inputBox.followupLoading}
</div> </div>
) : ( ) : (
<Suggestions className="min-h-16 w-fit items-start"> <Suggestions className="min-h-16 w-fit items-start">
@ -872,19 +880,21 @@ export function InputBox({
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle>{t.inputBox.followupConfirmTitle}</DialogTitle>
<DialogDescription> <DialogDescription>
{t.inputBox.followupConfirmDescription}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}> <Button variant="outline" onClick={() => setConfirmOpen(false)}>
{t.common.cancel}
</Button> </Button>
<Button variant="secondary" onClick={confirmAppendAndSend}> <Button variant="secondary" onClick={confirmAppendAndSend}>
{t.inputBox.followupConfirmAppend}
</Button>
<Button onClick={confirmReplaceAndSend}>
{t.inputBox.followupConfirmReplace}
</Button> </Button>
<Button onClick={confirmReplaceAndSend}></Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -1165,6 +1175,11 @@ function AttachmentPreviewBar({
threadId: string; threadId: string;
onRemoveReference: (reference: PromptInputReference) => void; onRemoveReference: (reference: PromptInputReference) => void;
}) { }) {
const { t } = useI18n();
const referenceSourceLabels = {
artifact: t.inputBox.referenceSourceArtifact,
upload: t.inputBox.referenceSourceUpload,
} as const;
const attachments = usePromptInputAttachments(); const attachments = usePromptInputAttachments();
const hasReferences = references.length > 0; const hasReferences = references.length > 0;
const hasAttachmentFiles = attachments.files.length > 0; const hasAttachmentFiles = attachments.files.length > 0;
@ -1214,7 +1229,7 @@ function AttachmentPreviewBar({
}} }}
data-testid="reference-chip" data-testid="reference-chip"
onRemove={() => onRemoveReference(reference)} 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)}` : ""}`}
/> />
); );
})} })}

View File

@ -387,11 +387,15 @@ function RichFileCard({
clear_target: true, clear_target: true,
}); });
setMaterializeMessage( setMaterializeMessage(
`已创建 ${result.created_files} 个文件 / ${result.created_directories} 个目录`, t.messageListItem.materializeSuccess(
result.created_files,
result.created_directories,
),
); );
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "解析失败"; const message =
setMaterializeMessage(`失败: ${message}`); error instanceof Error ? error.message : t.messageListItem.parseFailed;
setMaterializeMessage(t.messageListItem.materializeFailed(message));
} finally { } finally {
setIsMaterializing(false); setIsMaterializing(false);
} }
@ -430,7 +434,7 @@ function RichFileCard({
}); });
}} }}
> >
{t.common.reference}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
@ -470,7 +474,9 @@ function RichFileCard({
}} }}
disabled={isMaterializing} disabled={isMaterializing}
> >
{isMaterializing ? "解析中..." : "一键导入为 Skill 目录"} {isMaterializing
? t.messageListItem.materializing
: t.messageListItem.importAsSkillDir}
</Button> </Button>
{materializeMessage && ( {materializeMessage && (
<span className="text-muted-foreground text-[10px] leading-tight"> <span className="text-muted-foreground text-[10px] leading-tight">

View File

@ -228,7 +228,7 @@ export function MessageList({
"z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm", "z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm",
scrollButtonClassName, scrollButtonClassName,
)} )}
title="滚动到底部" title={t.chats.scrollToBottom}
/> />
)} )}
</Conversation> </Conversation>

View File

@ -38,7 +38,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/" className="text-primary ml-2 font-serif"> <Link href="/" className="text-primary ml-2 font-serif">
XClaw侧边栏 {t.workspaceHeader.sidebarTitle}
</Link> </Link>
) : ( ) : (
<div className="text-primary ml-2 cursor-default font-serif"> <div className="text-primary ml-2 cursor-default font-serif">

View File

@ -52,6 +52,7 @@ export const enUS: Translations = {
exportAsJSON: "Export as JSON", exportAsJSON: "Export as JSON",
exportSuccess: "Conversation exported", exportSuccess: "Conversation exported",
removeAttachment: "Remove attachment", removeAttachment: "Remove attachment",
reference: "Reference",
}, },
// Welcome // Welcome
@ -115,6 +116,13 @@ export const enUS: Translations = {
"You already have text in the input. Choose how to send it.", "You already have text in the input. Choose how to send it.",
followupConfirmAppend: "Append & send", followupConfirmAppend: "Append & send",
followupConfirmReplace: "Replace & 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: [ suggestions: [
{ {
suggestion: "Paper Writing", suggestion: "Paper Writing",
@ -248,6 +256,87 @@ export const enUS: Translations = {
// Chats // Chats
chats: { chats: {
searchChats: "Search 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) // Page titles (document title)

View File

@ -47,6 +47,7 @@ export interface Translations {
exportAsJSON: string; exportAsJSON: string;
exportSuccess: string; exportSuccess: string;
removeAttachment: string; removeAttachment: string;
reference: string;
}; };
// Welcome // Welcome
@ -99,6 +100,13 @@ export interface Translations {
followupConfirmDescription: string; followupConfirmDescription: string;
followupConfirmAppend: string; followupConfirmAppend: string;
followupConfirmReplace: string; followupConfirmReplace: string;
submit: string;
submitting: string;
stop: string;
addReference: string;
referenceSourceArtifact: string;
referenceSourceUpload: string;
maxReferencesReached: string;
suggestions: { suggestions: {
suggestion: string; suggestion: string;
prompt: string; prompt: string;
@ -179,6 +187,80 @@ export interface Translations {
// Chats // Chats
chats: { chats: {
searchChats: string; 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) // Page titles (document title)

View File

@ -54,6 +54,7 @@ export const zhCN: Translations = {
exportAsJSON: "导出为 JSON", exportAsJSON: "导出为 JSON",
exportSuccess: "对话已导出", exportSuccess: "对话已导出",
removeAttachment: "移除附件", removeAttachment: "移除附件",
reference: "引用",
}, },
// Welcome // Welcome
@ -112,6 +113,13 @@ export const zhCN: Translations = {
followupConfirmDescription: "当前输入框已有内容,选择发送方式。", followupConfirmDescription: "当前输入框已有内容,选择发送方式。",
followupConfirmAppend: "追加并发送", followupConfirmAppend: "追加并发送",
followupConfirmReplace: "替换并发送", followupConfirmReplace: "替换并发送",
submit: "发送",
submitting: "生成中...",
stop: "停止",
addReference: "添加引用",
referenceSourceArtifact: "生成文件",
referenceSourceUpload: "上传附件",
maxReferencesReached: "单条消息最多引用 10 个文件",
suggestions: [ suggestions: [
{ {
suggestion: "自媒体文案", suggestion: "自媒体文案",
@ -237,6 +245,83 @@ export const zhCN: Translations = {
// Chats // Chats
chats: { chats: {
searchChats: "搜索对话", 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) // Page titles (document title)

View File

@ -2,12 +2,15 @@ import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useI18n } from "../i18n/hooks";
import { loadModels } from "./api"; import { loadModels } from "./api";
import type { Model } from "./types"; import type { Model } from "./types";
const MODELS_UPDATING_TOAST_ID = "models-server-updating"; const MODELS_UPDATING_TOAST_ID = "models-server-updating";
export function useModels({ enabled = true }: { enabled?: boolean } = {}) { export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
const { t } = useI18n();
const { data, isLoading, error, failureReason } = useQuery<Model[], Error>({ const { data, isLoading, error, failureReason } = useQuery<Model[], Error>({
queryKey: ["models"], queryKey: ["models"],
queryFn: () => loadModels(), queryFn: () => loadModels(),
@ -31,7 +34,7 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
); );
if (serverError) { if (serverError) {
toast.loading("系统正在更新,请稍候……", { toast.loading(t.models.updating, {
id: MODELS_UPDATING_TOAST_ID, id: MODELS_UPDATING_TOAST_ID,
}); });
return; return;
@ -42,9 +45,9 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
useEffect(() => { useEffect(() => {
if (error?.message.includes("HTTP error: 4")) { if (error?.message.includes("HTTP error: 4")) {
toast.error("模型接口不可用,请检查后端路由或服务状态。"); toast.error(t.models.apiUnavailable);
} }
}, [error]); }, [error, t.models.apiUnavailable]);
return { models: data ?? [], isLoading, error }; return { models: data ?? [], isLoading, error };
} }

View File

@ -50,7 +50,6 @@ export type LegacyThreadStreamOptions = {
}; };
const STREAM_ERROR_FALLBACK_MESSAGE = "Request failed."; const STREAM_ERROR_FALLBACK_MESSAGE = "Request failed.";
const STREAM_ERROR_TOAST_MESSAGE = "出现了某些错误。";
const STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS = 2000; const STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS = 2000;
const STREAM_CANCEL_PATTERNS = [ const STREAM_CANCEL_PATTERNS = [
/\bcancellederror\b/i, /\bcancellederror\b/i,
@ -276,8 +275,8 @@ export function useThreadStream({
lastErrorToastRef.current = { message, timestamp: now }; lastErrorToastRef.current = { message, timestamp: now };
console.error("[useThreadStream] conversation stream error:", error); console.error("[useThreadStream] conversation stream error:", error);
console.error("[useThreadStream] parsed error message:", message); console.error("[useThreadStream] parsed error message:", message);
toast.error(STREAM_ERROR_TOAST_MESSAGE); toast.error(t.threads.streamError);
}, []); }, [t.threads.streamError]);
const handleStreamStart = useCallback( const handleStreamStart = useCallback(
(_threadId: string) => { (_threadId: string) => {
@ -407,7 +406,7 @@ export function useThreadStream({
normalizeThreadId(threadIdRef.current) ?? normalizeThreadId(threadIdRef.current) ??
undefined; undefined;
if (resolvedThreadId === "new") { if (resolvedThreadId === "new") {
toast.error("Invalid thread id 'new'. Please refresh and retry."); toast.error(t.threads.invalidThreadId);
sendInFlightRef.current = false; sendInFlightRef.current = false;
return; return;
} }
@ -503,12 +502,12 @@ export function useThreadStream({
if (failedConversions > 0) { if (failedConversions > 0) {
throw new Error( throw new Error(
`Failed to prepare ${failedConversions} attachment(s) for upload. Please retry.`, t.threads.uploadPrepareFailed(failedConversions),
); );
} }
if (!resolvedThreadId) { if (!resolvedThreadId) {
throw new Error("Thread is not ready for file upload."); throw new Error(t.threads.threadNotReadyForUpload);
} }
if (files.length > 0) { if (files.length > 0) {
@ -545,9 +544,7 @@ export function useThreadStream({
} catch (error) { } catch (error) {
console.error("Failed to upload files:", error); console.error("Failed to upload files:", error);
const errorMessage = const errorMessage =
error instanceof Error error instanceof Error ? error.message : t.threads.uploadFailed;
? error.message
: "Failed to upload files.";
toast.error(errorMessage); toast.error(errorMessage);
setOptimisticMessages([]); setOptimisticMessages([]);
throw error; throw error;
@ -563,7 +560,7 @@ export function useThreadStream({
normalizedReferences, normalizedReferences,
); );
if (staleCount > 0) { if (staleCount > 0) {
toast.error("部分引用文件已失效,已自动移除并继续发送。"); toast.error(t.threads.staleReferencesRemoved);
} }
await thread.submit( await thread.submit(
@ -621,6 +618,11 @@ export function useThreadStream({
thread, thread,
_handleOnStart, _handleOnStart,
t.uploads.uploadingFiles, t.uploads.uploadingFiles,
t.threads.invalidThreadId,
t.threads.uploadPrepareFailed,
t.threads.threadNotReadyForUpload,
t.threads.uploadFailed,
t.threads.staleReferencesRemoved,
context, context,
queryClient, queryClient,
apiClient, apiClient,
@ -659,12 +661,13 @@ export function useSubmitThread({
uploadTarget?: UploadTarget; uploadTarget?: UploadTarget;
afterSubmit?: () => void; afterSubmit?: () => void;
}) { }) {
const { t } = useI18n();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const apiClient = getAPIClient(); const apiClient = getAPIClient();
const callback = useCallback( const callback = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (threadId === "new") { if (threadId === "new") {
toast.error("Invalid thread id 'new'. Please refresh and retry."); toast.error(t.threads.invalidThreadId);
return; return;
} }
const text = message.text.trim(); const text = message.text.trim();
@ -730,7 +733,7 @@ export function useSubmitThread({
normalizedReferences, normalizedReferences,
); );
if (staleCount > 0) { if (staleCount > 0) {
toast.error("部分引用文件已失效,已自动移除并继续发送。"); toast.error(t.threads.staleReferencesRemoved);
} }
await thread.submit( await thread.submit(
@ -769,6 +772,8 @@ export function useSubmitThread({
}, },
[ [
thread, thread,
t.threads.invalidThreadId,
t.threads.staleReferencesRemoved,
createNewSession, createNewSession,
threadId, threadId,
threadContext, threadContext,

View File

@ -10,6 +10,7 @@ import {
type SelectedSkillPayloadItem, type SelectedSkillPayloadItem,
sendToParent, sendToParent,
} from "@/core/iframe-messages"; } from "@/core/iframe-messages";
import { useI18n } from "@/core/i18n/hooks";
import { bootstrapRemoteSkill } from "@/core/skills/api"; import { bootstrapRemoteSkill } from "@/core/skills/api";
// Skill 数据类型 // Skill 数据类型
@ -79,6 +80,7 @@ interface UseIframeSkillOptions {
export function useIframeSkill( export function useIframeSkill(
options?: UseIframeSkillOptions, options?: UseIframeSkillOptions,
): UseIframeSkillReturn { ): UseIframeSkillReturn {
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const threadIdFromQuery = searchParams.get("thread_id"); const threadIdFromQuery = searchParams.get("thread_id");
@ -251,8 +253,8 @@ export function useIframeSkill(
title: string; title: string;
}) => { }) => {
if (!threadId) { if (!threadId) {
toast.error("技能加载失败", { toast.error(t.skills.loadFailed, {
description: "缺少 thread_id无法初始化技能", description: t.skills.missingThreadId,
}); });
return false; return false;
} }
@ -266,8 +268,8 @@ export function useIframeSkill(
); );
if (content_ids.length === 0) { if (content_ids.length === 0) {
toast.error("技能加载失败", { toast.error(t.skills.loadFailed, {
description: "无效的 skill_id", description: t.skills.invalidSkillId,
}); });
return false; return false;
} }
@ -278,7 +280,7 @@ export function useIframeSkill(
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
setIsBootstrapping(true); setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { toast.loading(t.skills.loading(title), {
id: "suggest-skill-bootstrap", id: "suggest-skill-bootstrap",
}); });
@ -298,8 +300,8 @@ export function useIframeSkill(
String(item.id).trim(), String(item.id).trim(),
); );
removeFailedSkills(failedIds); removeFailedSkills(failedIds);
toast.error(`技能「${title}」加载失败`, { toast.error(t.skills.loadFailedWithTitle(title), {
description: result.message || "未知错误", description: result.message || t.skills.unknownError,
}); });
return false; return false;
} }
@ -312,9 +314,8 @@ export function useIframeSkill(
setSelectedSkill(normalizedSkills[0] ?? null); setSelectedSkill(normalizedSkills[0] ?? null);
setSelectedSkills(normalizedSkills); setSelectedSkills(normalizedSkills);
toast.success(`技能「${title}」加载成功`, { toast.success(t.skills.loadSuccessWithTitle(title), {
description: description: result.message || t.skills.createdFiles(result.created_files),
result.message || `已创建 ${result.created_files} 个文件`,
}); });
return true; return true;
@ -322,8 +323,9 @@ export function useIframeSkill(
const failedIds = selectedSkills.map((item) => String(item.id).trim()); const failedIds = selectedSkills.map((item) => String(item.id).trim());
removeFailedSkills(failedIds); removeFailedSkills(failedIds);
toast.dismiss("suggest-skill-bootstrap"); toast.dismiss("suggest-skill-bootstrap");
const message = error instanceof Error ? error.message : "网络请求失败"; const message =
toast.error(`技能「${title}」加载失败`, { error instanceof Error ? error.message : t.skills.networkRequestFailed;
toast.error(t.skills.loadFailedWithTitle(title), {
description: message, description: message,
}); });
return false; return false;
@ -331,7 +333,7 @@ export function useIframeSkill(
setIsBootstrapping(false); setIsBootstrapping(false);
} }
}, },
[removeFailedSkills, searchParams, sendSelectSkill, threadId], [removeFailedSkills, searchParams, sendSelectSkill, t.skills, threadId],
); );
// 打开 skill 选择对话框 // 打开 skill 选择对话框

View File

@ -7,6 +7,7 @@ import {
isSelectedSkillsMessage, isSelectedSkillsMessage,
type SelectedSkillPayloadItem, type SelectedSkillPayloadItem,
} from "@/core/iframe-messages"; } from "@/core/iframe-messages";
import { useI18n } from "@/core/i18n/hooks";
import { bootstrapRemoteSkill } from "@/core/skills/api"; import { bootstrapRemoteSkill } from "@/core/skills/api";
/** 技能基础数据 */ /** 技能基础数据 */
@ -46,6 +47,7 @@ interface UseSelectedSkillListenerReturn {
export function useSelectedSkillListener({ export function useSelectedSkillListener({
threadId, threadId,
}: UseSelectedSkillListenerOptions): UseSelectedSkillListenerReturn { }: UseSelectedSkillListenerOptions): UseSelectedSkillListenerReturn {
const { t } = useI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null); const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
const [skillError, setSkillError] = useState<SkillError | null>(null); const [skillError, setSkillError] = useState<SkillError | null>(null);
@ -67,8 +69,8 @@ export function useSelectedSkillListener({
if (contentIds.length === 0) { if (contentIds.length === 0) {
console.warn("[useSelectedSkillListener] 忽略非法 skill ids", skills); console.warn("[useSelectedSkillListener] 忽略非法 skill ids", skills);
setSkillError({ setSkillError({
title: `技能「${title}」加载失败`, title: t.skills.loadFailedWithTitle(title),
message: "非法 skill_id 数组", message: t.skills.invalidSkillIdArray,
}); });
return; return;
} }
@ -87,7 +89,7 @@ export function useSelectedSkillListener({
`[useSelectedSkillListener] 开始初始化技能: ${title} (${contentIds.join(",")})`, `[useSelectedSkillListener] 开始初始化技能: ${title} (${contentIds.join(",")})`,
); );
setIsBootstrapping(true); setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" }); toast.loading(t.skills.loading(title), { id: "skill-bootstrap" });
try { try {
const result = await bootstrapRemoteSkill({ const result = await bootstrapRemoteSkill({
@ -102,26 +104,26 @@ export function useSelectedSkillListener({
if (result.success) { if (result.success) {
skillBootstrappedKeyRef.current = initKey; skillBootstrappedKeyRef.current = initKey;
toast.success(`技能「${title}」加载成功`, { toast.success(t.skills.loadSuccessWithTitle(title), {
description: description: result.message || t.skills.createdFiles(result.created_files),
result.message || `已创建 ${result.created_files} 个文件`,
duration: 4000, duration: 4000,
}); });
} else { } else {
setSkillError({ setSkillError({
title: `技能「${title}」加载失败`, title: t.skills.loadFailedWithTitle(title),
message: result.message || "未知错误", message: result.message || t.skills.unknownError,
}); });
} }
} catch (err) { } catch (err) {
toast.dismiss("skill-bootstrap"); toast.dismiss("skill-bootstrap");
const message = err instanceof Error ? err.message : "网络请求失败"; const message =
setSkillError({ title: `技能「${title}」加载出错`, message }); err instanceof Error ? err.message : t.skills.networkRequestFailed;
setSkillError({ title: t.skills.loadErrorWithTitle(title), message });
} finally { } finally {
setIsBootstrapping(false); setIsBootstrapping(false);
} }
}, },
[threadId, searchParams], [threadId, searchParams, t.skills],
); );
// 1. URL 初始化集成 // 1. URL 初始化集成