继续填充多语言处理

This commit is contained in:
xintaofei
2026-03-07 14:00:29 +08:00
parent a356b813a6
commit 47189318e5
11 changed files with 322 additions and 69 deletions

View File

@@ -1,6 +1,7 @@
"use client"
import { memo, useMemo, useState } from "react"
import { useTranslations } from "next-intl"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import type { LiveMessage } from "@/contexts/acp-connections-context"
@@ -36,29 +37,41 @@ function getLatestPlanEntries(message: LiveMessage | null): PlanEntryInfo[] {
return []
}
function getStatusLabel(status: string): string {
function getStatusKey(
status: string
):
| "status.completed"
| "status.inProgress"
| "status.pending"
| "status.unknown" {
switch (status) {
case "completed":
return "Completed"
return "status.completed"
case "in_progress":
return "In Progress"
return "status.inProgress"
case "pending":
return "Pending"
return "status.pending"
default:
return "Unknown"
return "status.unknown"
}
}
function getPriorityLabel(priority: string): string {
type PriorityKey =
| "priority.high"
| "priority.medium"
| "priority.low"
| "priority.unknown"
function getPriorityKey(priority: string): PriorityKey {
switch (priority) {
case "high":
return "High"
return "priority.high"
case "medium":
return "Medium"
return "priority.medium"
case "low":
return "Low"
return "priority.low"
default:
return "Unknown"
return "priority.unknown"
}
}
@@ -94,6 +107,7 @@ export const AgentPlanOverlay = memo(function AgentPlanOverlay({
visible = true,
defaultExpanded = true,
}: AgentPlanOverlayProps) {
const t = useTranslations("Folder.chat.agentPlanOverlay")
const liveEntries = useMemo(
() => getLatestPlanEntries(message ?? null),
[message]
@@ -146,7 +160,10 @@ export const AgentPlanOverlay = memo(function AgentPlanOverlay({
}
>
<ListTodoIcon className="h-4 w-4" />
Plan {completedCount}/{resolvedEntries.length}
{t("collapsedSummary", {
completed: completedCount,
total: resolvedEntries.length,
})}
<ChevronUpIcon className="h-4 w-4" />
</Button>
</div>
@@ -162,7 +179,7 @@ export const AgentPlanOverlay = memo(function AgentPlanOverlay({
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<ListTodoIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate">Agent Plan</span>
<span className="text-sm font-medium truncate">{t("title")}</span>
<Badge variant="secondary" className="h-5">
{completedCount}/{resolvedEntries.length}
</Badge>
@@ -171,7 +188,7 @@ export const AgentPlanOverlay = memo(function AgentPlanOverlay({
type="button"
variant="ghost"
size="icon-xs"
aria-label="Collapse plan"
aria-label={t("collapsePlanAria")}
onClick={() =>
setCollapsedByPlanKey((prev) => ({
...prev,
@@ -204,7 +221,7 @@ export const AgentPlanOverlay = memo(function AgentPlanOverlay({
</div>
<div className="mt-2 flex items-center gap-1.5 pl-5">
<Badge variant="outline" className="h-5 text-[10px] uppercase">
{getStatusLabel(entry.status)}
{t(getStatusKey(entry.status))}
</Badge>
<Badge
variant="outline"
@@ -213,7 +230,7 @@ export const AgentPlanOverlay = memo(function AgentPlanOverlay({
getPriorityClassName(entry.priority)
)}
>
{getPriorityLabel(entry.priority)}
{t(getPriorityKey(entry.priority))}
</Badge>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { useTranslations } from "next-intl"
import { acpListAgents } from "@/lib/tauri"
import type { AgentType, AcpAgentInfo } from "@/lib/types"
import { AGENT_LABELS } from "@/lib/types"
@@ -22,6 +23,7 @@ export function AgentSelector({
onOpenAgentsSettings,
disabled = false,
}: AgentSelectorProps) {
const t = useTranslations("Folder.chat.agentSelector")
const [agents, setAgents] = useState<AcpAgentInfo[]>([])
const [selected, setSelected] = useState<AgentType | null>(
defaultAgentType ?? null
@@ -80,14 +82,14 @@ export function AgentSelector({
if (agents.length === 0) {
return (
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
<div> Agent</div>
<div>{t("noEnabledAgents")}</div>
{onOpenAgentsSettings ? (
<button
type="button"
onClick={onOpenAgentsSettings}
className="mt-2 inline-flex items-center rounded-md border px-2 py-1 text-xs text-foreground transition-colors hover:bg-accent cursor-pointer"
>
Agents
{t("openAgentsSettings")}
</button>
) : null}
</div>

View File

@@ -1,5 +1,6 @@
"use client"
import { useTranslations } from "next-intl"
import type {
ConnectionStatus,
PromptDraft,
@@ -44,6 +45,7 @@ export function ChatInput({
attachmentTabId,
draftStorageKey,
}: ChatInputProps) {
const t = useTranslations("Folder.chat.chatInput")
const isConnected = status === "connected"
const isPrompting = status === "prompting"
const isConnecting = status === "connecting" || status === "downloading"
@@ -69,10 +71,10 @@ export function ChatInput({
draftStorageKey={draftStorageKey}
placeholder={
isConnecting
? "Connecting..."
? t("connecting")
: isPrompting
? "Agent is responding..."
: "Send a message..."
? t("agentResponding")
: t("sendMessage")
}
className="min-h-28 max-h-60"
/>

View File

@@ -1,6 +1,7 @@
"use client"
import { memo, useMemo } from "react"
import { useTranslations } from "next-intl"
import type { LiveMessage } from "@/contexts/acp-connections-context"
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
import { adaptLiveMessageFromAcp } from "@/lib/adapters/ai-elements-adapter"
@@ -13,6 +14,7 @@ interface LiveMessageBlockProps {
export const LiveMessageBlock = memo(function LiveMessageBlock({
message,
}: LiveMessageBlockProps) {
const t = useTranslations("Folder.chat.liveMessageBlock")
const hasContent = message.content.length > 0
const adapted = useMemo(() => adaptLiveMessageFromAcp(message), [message])
@@ -24,7 +26,7 @@ export const LiveMessageBlock = memo(function LiveMessageBlock({
) : (
<div
className="flex items-center gap-1.5 text-muted-foreground py-1"
aria-label="Assistant is thinking"
aria-label={t("assistantThinkingAria")}
>
<span className="h-2 w-2 rounded-full bg-primary animate-bounce [animation-delay:-0.3s]" />
<span className="h-2 w-2 rounded-full bg-primary animate-bounce [animation-delay:-0.15s]" />

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { open } from "@tauri-apps/plugin-dialog"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { FileSearch, Plus, Send, Square, X } from "lucide-react"
@@ -114,7 +115,7 @@ function SelectorLoadingChip({ label }: { label: string }) {
export function MessageInput({
onSend,
placeholder = "Ask anything...",
placeholder,
defaultPath,
disabled = false,
autoFocus = false,
@@ -133,7 +134,9 @@ export function MessageInput({
attachmentTabId,
draftStorageKey,
}: MessageInputProps) {
const t = useTranslations("Folder.chat.messageInput")
const effectiveDraftStorageKey = draftStorageKey ?? attachmentTabId ?? null
const resolvedPlaceholder = placeholder ?? t("askAnything")
const [text, setText] = useState(() => {
if (!effectiveDraftStorageKey) return ""
return loadMessageInputDraft(effectiveDraftStorageKey) ?? ""
@@ -388,7 +391,7 @@ export function MessageInput({
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
onFocus={onFocus}
placeholder={placeholder}
placeholder={resolvedPlaceholder}
className={cn(
"text-sm pr-12 resize-none bg-transparent",
topPaddingClass,
@@ -411,7 +414,9 @@ export function MessageInput({
type="button"
onClick={() => removeAttachment(attachment.path)}
className="rounded-sm p-0.5 hover:bg-muted-foreground/15"
aria-label={`Remove ${attachment.name}`}
aria-label={t("removeAttachmentAria", {
name: attachment.name,
})}
>
<X className="h-3 w-3" />
</button>
@@ -428,12 +433,12 @@ export function MessageInput({
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
title="Attach files"
title={t("attachFiles")}
>
<Plus className="size-4" />
</Button>
{showConfigLoading && (
<SelectorLoadingChip label="Loading settings..." />
<SelectorLoadingChip label={t("loadingSettings")} />
)}
{hasConfigOptions &&
availableConfigOptions.map((option) => (
@@ -445,7 +450,7 @@ export function MessageInput({
}
/>
))}
{showModeLoading && <SelectorLoadingChip label="Loading mode..." />}
{showModeLoading && <SelectorLoadingChip label={t("loadingMode")} />}
{showModeSelector && effectiveModeId && (
<ModeSelector
modes={availableModes}
@@ -461,7 +466,7 @@ export function MessageInput({
variant="destructive"
size="icon"
className="absolute right-2 bottom-2"
title="Cancel"
title={t("cancel")}
>
<Square className="h-4 w-4" />
</Button>
@@ -471,7 +476,7 @@ export function MessageInput({
disabled={disabled || !hasSendableContent}
size="icon"
className="absolute right-2 bottom-2"
title="Send"
title={t("send")}
>
<Send className="h-4 w-4" />
</Button>

View File

@@ -1,6 +1,7 @@
"use client"
import { useMemo } from "react"
import { useTranslations } from "next-intl"
import {
ShieldAlert,
Terminal,
@@ -19,15 +20,16 @@ interface PermissionDialogProps {
onRespond: (requestId: string, optionId: string) => void
}
function formatKindLabel(kind: string): string {
function formatKindLabel(kind: string, fallbackLabel: string): string {
const normalized = kind.replace(/_/g, " ").trim()
return normalized.length > 0 ? normalized : "tool"
return normalized.length > 0 ? normalized : fallbackLabel
}
export function PermissionDialog({
permission,
onRespond,
}: PermissionDialogProps) {
const t = useTranslations("Folder.chat.permissionDialog")
const parsed = useMemo(
() => parsePermissionToolCall(permission?.tool_call),
[permission?.tool_call]
@@ -51,12 +53,10 @@ export function PermissionDialog({
<ShieldAlert className="h-4 w-4 shrink-0 text-amber-500" />
<span className="truncate">{parsed.title}</span>
</div>
<p className="text-xs text-muted-foreground">
Agent requests permission to continue this turn.
</p>
<p className="text-xs text-muted-foreground">{t("subtitle")}</p>
</div>
<Badge variant="outline" className="shrink-0 text-[10px]">
{formatKindLabel(parsed.normalizedKind)}
{formatKindLabel(parsed.normalizedKind, t("kindFallbackTool"))}
</Badge>
</div>
@@ -65,12 +65,12 @@ export function PermissionDialog({
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Terminal className="h-3.5 w-3.5" />
<span>Command</span>
<span>{t("command")}</span>
</div>
<CodeBlock code={parsed.command} language="bash" />
{parsed.cwd && (
<div className="break-all text-xs text-muted-foreground">
CWD: {parsed.cwd}
{t("cwd", { cwd: parsed.cwd })}
</div>
)}
</div>
@@ -80,7 +80,9 @@ export function PermissionDialog({
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<FilePenLine className="h-3.5 w-3.5" />
<span>Files: {parsed.fileChanges.length}</span>
<span>
{t("filesSummary", { count: parsed.fileChanges.length })}
</span>
{(parsed.additions > 0 || parsed.deletions > 0) && (
<span>
+{parsed.additions} / -{parsed.deletions}
@@ -98,7 +100,7 @@ export function PermissionDialog({
))}
{parsed.fileChanges.length > 8 && (
<div className="text-xs text-muted-foreground">
+{parsed.fileChanges.length - 8} more files
{t("moreFiles", { count: parsed.fileChanges.length - 8 })}
</div>
)}
</div>
@@ -112,7 +114,7 @@ export function PermissionDialog({
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<ListTodo className="h-3.5 w-3.5" />
<span>Plan</span>
<span>{t("plan")}</span>
</div>
{parsed.planExplanation && (
<p className="text-xs text-foreground/90">
@@ -140,7 +142,7 @@ export function PermissionDialog({
<div className="rounded-md border border-border/60 bg-muted/20 p-2 text-xs">
<div className="flex items-center gap-1 text-muted-foreground">
<Compass className="h-3.5 w-3.5" />
<span>Target mode: {parsed.modeTarget}</span>
<span>{t("targetMode", { mode: parsed.modeTarget })}</span>
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslations } from "next-intl"
import { MessageInput } from "@/components/chat/message-input"
import type { AgentType, PromptDraft, SessionStats } from "@/lib/types"
import { useFolderContext } from "@/contexts/folder-context"
@@ -65,18 +66,18 @@ function isExpectedAutoLinkError(error: unknown): boolean {
return (error as { alerted?: unknown }).alerted === true
}
function buildInlineAutoConnectErrorMessage(raw: string): string {
const normalized = raw.trim().replace(/[.!?,;:]+$/u, "")
if (!normalized) return "点击前往设置 > Agents 管理安装。"
const hasSdkNotInstalled = /SDK\s*$/u.test(normalized)
const message =
!hasSdkNotInstalled && normalized.endsWith("尚未安装")
? normalized.replace(/$/u, "SDK ")
: normalized
if (message.includes("设置 > Agents 管理安装")) {
return `${message}`
function buildInlineAutoConnectErrorMessage(
raw: string,
options: {
fallback: string
append: (message: string) => string
alreadyContainsPath: (message: string) => boolean
}
return `${message},点击前往设置 > Agents 管理安装。`
): string {
const normalized = raw.trim().replace(/[.!?,;:]+$/u, "")
if (!normalized) return options.fallback
if (options.alreadyContainsPath(normalized)) return normalized
return options.append(normalized)
}
export function WelcomeInputPanel({
@@ -85,6 +86,7 @@ export function WelcomeInputPanel({
tabId,
isActive = true,
}: WelcomeInputPanelProps) {
const t = useTranslations("Folder.chat.welcomeInputPanel")
const fallbackContextId = useMemo(() => crypto.randomUUID(), [])
const contextKey = tabId ?? `new-${fallbackContextId}`
@@ -569,6 +571,23 @@ export function WelcomeInputPanel({
})
}, [selectedAgent])
const buildAutoConnectErrorMessage = useCallback(
(raw: string) =>
buildInlineAutoConnectErrorMessage(raw, {
fallback: t("autoConnectFallback"),
append: (message) =>
t("autoConnectAppend", {
message,
path: t("agentsSettingsPath"),
}),
alreadyContainsPath: (message) =>
[t("agentsSettingsPath"), "Settings > Agents"].some((path) =>
message.includes(path)
),
}),
[t]
)
// Track live message visibility across turn completion.
// Hooks must be called before any conditional returns.
const prevConnStatusForLiveRef = useRef(connStatus)
@@ -617,7 +636,7 @@ export function WelcomeInputPanel({
className="w-full cursor-pointer rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-center text-xs text-destructive transition-colors hover:bg-destructive/10"
>
{(() => {
const inlineMessage = buildInlineAutoConnectErrorMessage(
const inlineMessage = buildAutoConnectErrorMessage(
autoConnectError ?? agentConnectError ?? ""
)
return (
@@ -638,8 +657,8 @@ export function WelcomeInputPanel({
defaultPath={workingDir}
placeholder={
agentsLoaded && usableAgentCount === 0
? "请先启用至少一个 Agent 后开始会话..."
: "Ask anything..."
? t("enableAgentFirstPlaceholder")
: t("askAnythingPlaceholder")
}
autoFocus
attachmentTabId={tabId ?? null}

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react"
import dynamic from "next/dynamic"
import type { editor as MonacoEditorNs } from "monaco-editor"
import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context"
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
import { cn } from "@/lib/utils"
@@ -414,11 +415,13 @@ function parseUnifiedDiff(diffText: string): ParsedDiffFile[] {
.filter((file) => file.hunks.length > 0)
}
function modeLabel(mode: DiffFileMode): string {
if (mode === "added") return "新增"
if (mode === "deleted") return "删除"
if (mode === "renamed") return "重命名"
return "修改"
function modeKey(
mode: DiffFileMode
): "mode.added" | "mode.deleted" | "mode.renamed" | "mode.modified" {
if (mode === "added") return "mode.added"
if (mode === "deleted") return "mode.deleted"
if (mode === "renamed") return "mode.renamed"
return "mode.modified"
}
function toDisplayPath(filePath: string, folderPath: string | null): string {
@@ -436,11 +439,6 @@ function toDisplayPath(filePath: string, folderPath: string | null): string {
return normalizedPath
}
function hunkLabel(hunk: ParsedDiffHunk, index: number): string {
void hunk
return `Hunk ${index + 1}`
}
function countHunkChanges(hunk: ParsedDiffHunk): {
additions: number
deletions: number
@@ -485,6 +483,7 @@ function HunkMonacoPreview({
modelId: string
theme: string
}) {
const t = useTranslations("Folder.diffPreview")
const editorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(null)
const decorationsRef = useRef<string[]>([])
@@ -567,7 +566,7 @@ function HunkMonacoPreview({
theme={theme}
loading={
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">
Loading hunk...
{t("loadingHunk")}
</div>
}
options={{
@@ -601,6 +600,7 @@ export function UnifiedDiffPreview({
modelId?: string
className?: string
}) {
const t = useTranslations("Folder.diffPreview")
const { folder } = useFolderContext()
const files = useMemo(() => parseUnifiedDiff(diffText), [diffText])
const theme = useMonacoThemeSync()
@@ -613,7 +613,7 @@ export function UnifiedDiffPreview({
className
)}
>
No diff data
{t("noDiffData")}
</div>
)
}
@@ -638,7 +638,7 @@ export function UnifiedDiffPreview({
>
<header className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
<span className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
{modeLabel(file.mode)}
{t(modeKey(file.mode))}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-foreground"
@@ -666,7 +666,7 @@ export function UnifiedDiffPreview({
className="rounded-md border border-border"
>
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
<span>{hunkLabel(hunk, index)}</span>
<span>{t("hunkLabel", { index: index + 1 })}</span>
<span className="ml-auto inline-flex items-center gap-2">
<span className="text-green-700 dark:text-green-400">
+{hunkStats.additions}