继续多语言补充
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { cjk } from "@streamdown/cjk"
|
||||
import { code } from "@streamdown/code"
|
||||
import { math } from "@streamdown/math"
|
||||
@@ -258,11 +259,12 @@ export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const t = useTranslations("Folder.chat.messageBranch")
|
||||
const { goToPrevious, totalBranches } = useMessageBranch()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
aria-label={t("previousBranchAria")}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
@@ -281,11 +283,12 @@ export const MessageBranchNext = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const t = useTranslations("Folder.chat.messageBranch")
|
||||
const { goToNext, totalBranches } = useMessageBranch()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
aria-label={t("nextBranchAria")}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
@@ -304,6 +307,7 @@ export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const t = useTranslations("Folder.chat.messageBranch")
|
||||
const { currentBranch, totalBranches } = useMessageBranch()
|
||||
|
||||
return (
|
||||
@@ -314,7 +318,7 @@ export const MessageBranchPage = ({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
{t("pageOf", { current: currentBranch + 1, total: totalBranches })}
|
||||
</ButtonGroupText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -155,24 +156,29 @@ export type ReasoningTriggerProps = ComponentProps<
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
|
||||
}
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>
|
||||
}
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
getThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const t = useTranslations("Folder.chat.reasoning")
|
||||
const { isStreaming, isOpen, duration } = useReasoning()
|
||||
const defaultGetThinkingMessage = useCallback(
|
||||
(nextIsStreaming: boolean, nextDuration?: number) => {
|
||||
if (nextIsStreaming || nextDuration === 0) {
|
||||
return <Shimmer duration={1}>{t("thinking")}</Shimmer>
|
||||
}
|
||||
if (nextDuration === undefined) {
|
||||
return <p>{t("thoughtForFewSeconds")}</p>
|
||||
}
|
||||
return <p>{t("thoughtForSeconds", { duration: nextDuration })}</p>
|
||||
},
|
||||
[t]
|
||||
)
|
||||
const thinkingMessageBuilder =
|
||||
getThinkingMessage ?? defaultGetThinkingMessage
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
@@ -185,7 +191,7 @@ export const ReasoningTrigger = memo(
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
{thinkingMessageBuilder(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ComponentProps, HTMLAttributes } from "react"
|
||||
import Ansi from "ansi-to-react"
|
||||
import { CheckIcon, CopyIcon, TerminalIcon, Trash2Icon } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -127,6 +128,7 @@ export function TerminalTitle({
|
||||
children,
|
||||
...props
|
||||
}: TerminalTitleProps) {
|
||||
const t = useTranslations("Folder.chat.terminal")
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -136,7 +138,7 @@ export function TerminalTitle({
|
||||
{...props}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
{children ?? "Terminal"}
|
||||
{children ?? t("title")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -148,6 +150,7 @@ export function TerminalStatus({
|
||||
children,
|
||||
...props
|
||||
}: TerminalStatusProps) {
|
||||
const t = useTranslations("Folder.chat.terminal")
|
||||
const { isStreaming } = useContext(TerminalContext)
|
||||
|
||||
if (!isStreaming) {
|
||||
@@ -162,7 +165,7 @@ export function TerminalStatus({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Shimmer>Running</Shimmer>}
|
||||
{children ?? <Shimmer>{t("running")}</Shimmer>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { isValidElement } from "react"
|
||||
|
||||
import { CodeBlock } from "./code-block"
|
||||
@@ -47,16 +48,6 @@ export type ToolHeaderProps = {
|
||||
}
|
||||
)
|
||||
|
||||
const statusLabels: Record<ToolPart["state"], string> = {
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"input-available": "Running",
|
||||
"input-streaming": "Pending",
|
||||
"output-available": "Completed",
|
||||
"output-denied": "Denied",
|
||||
"output-error": "Error",
|
||||
}
|
||||
|
||||
const statusIcons: Record<ToolPart["state"], ReactNode> = {
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
@@ -67,10 +58,10 @@ const statusIcons: Record<ToolPart["state"], ReactNode> = {
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
}
|
||||
|
||||
export const getStatusBadge = (status: ToolPart["state"]) => (
|
||||
export const getStatusBadge = (status: ToolPart["state"], label: string) => (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{statusIcons[status]}
|
||||
{statusLabels[status]}
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
@@ -84,8 +75,23 @@ export const ToolHeader = ({
|
||||
toolName,
|
||||
...props
|
||||
}: ToolHeaderProps) => {
|
||||
const t = useTranslations("Folder.chat.tool")
|
||||
const derivedName =
|
||||
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-")
|
||||
const statusLabel =
|
||||
state === "approval-requested"
|
||||
? t("status.approvalRequested")
|
||||
: state === "approval-responded"
|
||||
? t("status.approvalResponded")
|
||||
: state === "input-available"
|
||||
? t("status.inputAvailable")
|
||||
: state === "input-streaming"
|
||||
? t("status.inputStreaming")
|
||||
: state === "output-available"
|
||||
? t("status.outputAvailable")
|
||||
: state === "output-denied"
|
||||
? t("status.outputDenied")
|
||||
: t("status.outputError")
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
@@ -103,7 +109,7 @@ export const ToolHeader = ({
|
||||
{title ?? derivedName}
|
||||
</span>
|
||||
{titleSuffix ? <span className="shrink-0">{titleSuffix}</span> : null}
|
||||
<span className="shrink-0">{getStatusBadge(state)}</span>
|
||||
<span className="shrink-0">{getStatusBadge(state, statusLabel)}</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
@@ -127,6 +133,7 @@ export type ToolInputProps = ComponentProps<"div"> & {
|
||||
}
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
|
||||
const t = useTranslations("Folder.chat.tool")
|
||||
const formattedCode = (() => {
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
@@ -142,7 +149,7 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
|
||||
return (
|
||||
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
{t("parameters")}
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={formattedCode} language="json" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BundledLanguage } from "shiki"
|
||||
import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter"
|
||||
import type { MessageRole } from "@/lib/types"
|
||||
import { normalizeToolName } from "@/lib/tool-call-normalization"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
countUnifiedDiffLineChanges,
|
||||
estimateChangedLineStats,
|
||||
@@ -1881,6 +1882,7 @@ const ToolCallPart = memo(function ToolCallPart({
|
||||
}: {
|
||||
part: Extract<AdaptedContentPart, { type: "tool-call" }>
|
||||
}) {
|
||||
const t = useTranslations("Folder.chat.contentParts")
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
const normalizedToolName = useMemo(
|
||||
() => normalizeToolName(part.toolName),
|
||||
@@ -2046,7 +2048,7 @@ const ToolCallPart = memo(function ToolCallPart({
|
||||
/>
|
||||
{liveOutputTruncated && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Showing tail output while streaming for performance.
|
||||
{t("showingTailOutput")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2068,9 +2070,14 @@ const ToolResultPart = memo(function ToolResultPart({
|
||||
}: {
|
||||
part: Extract<AdaptedContentPart, { type: "tool-result" }>
|
||||
}) {
|
||||
const t = useTranslations("Folder.chat.contentParts")
|
||||
return (
|
||||
<Tool>
|
||||
<ToolHeader type="dynamic-tool" state={part.state} toolName="Result" />
|
||||
<ToolHeader
|
||||
type="dynamic-tool"
|
||||
state={part.state}
|
||||
toolName={t("result")}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolOutput output={part.output} errorText={part.errorText} />
|
||||
</ToolContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
||||
import {
|
||||
@@ -9,11 +10,6 @@ import {
|
||||
} from "@/lib/line-change-stats"
|
||||
import { FilePenLine, Timer, Wrench } from "lucide-react"
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`
|
||||
return `${(ms / 1_000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
interface LiveTurnStatsProps {
|
||||
message: LiveMessage
|
||||
}
|
||||
@@ -27,14 +23,9 @@ interface LiveEditStats extends LineChangeStats {
|
||||
files: number
|
||||
}
|
||||
|
||||
const COMPACT_NUMBER = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
function formatCompactInt(n: number): string {
|
||||
function formatCompactInt(n: number, formatter: Intl.NumberFormat): string {
|
||||
if (n < 1000) return String(n)
|
||||
return COMPACT_NUMBER.format(n).toUpperCase()
|
||||
return formatter.format(n)
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
@@ -267,8 +258,18 @@ function extractLiveEditStats(message: LiveMessage): LiveEditStats {
|
||||
}
|
||||
|
||||
export function LiveTurnStats({ message }: LiveTurnStatsProps) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations("Folder.chat.liveTurnStats")
|
||||
const [elapsed, setElapsed] = useState(() => Date.now() - message.startedAt)
|
||||
const editStats = useMemo(() => extractLiveEditStats(message), [message])
|
||||
const compactNumberFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}),
|
||||
[locale]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
@@ -295,26 +296,32 @@ export function LiveTurnStats({ message }: LiveTurnStatsProps) {
|
||||
isThinking = true
|
||||
}
|
||||
|
||||
const elapsedLabel =
|
||||
elapsed >= 60_000
|
||||
? t("elapsedMinutes", { value: (elapsed / 60_000).toFixed(1) })
|
||||
: t("elapsedSeconds", { value: (elapsed / 1_000).toFixed(1) })
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 items-center justify-center gap-3 px-4 text-xs leading-none text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse shrink-0" />
|
||||
{isThinking && message.content.length <= 1 ? (
|
||||
<span>Thinking...</span>
|
||||
<span>{t("thinking")}</span>
|
||||
) : (
|
||||
<span>Streaming</span>
|
||||
<span>{t("streaming")}</span>
|
||||
)}
|
||||
<span className="text-border leading-none">|</span>
|
||||
<span className="inline-flex items-center gap-1 leading-none">
|
||||
<Timer className="h-3 w-3 shrink-0" />
|
||||
{formatElapsed(elapsed)}
|
||||
{elapsedLabel}
|
||||
</span>
|
||||
{editStats.files > 0 && (
|
||||
<>
|
||||
<span className="text-border leading-none">|</span>
|
||||
<span className="inline-flex items-center gap-1 leading-none">
|
||||
<FilePenLine className="h-3 w-3 shrink-0" />
|
||||
{editStats.files}F +{formatCompactInt(editStats.additions)}/-
|
||||
{formatCompactInt(editStats.deletions)}
|
||||
{editStats.files}F +
|
||||
{formatCompactInt(editStats.additions, compactNumberFormatter)}/-
|
||||
{formatCompactInt(editStats.deletions, compactNumberFormatter)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -323,7 +330,7 @@ export function LiveTurnStats({ message }: LiveTurnStatsProps) {
|
||||
<span className="text-border leading-none">|</span>
|
||||
<span className="inline-flex items-center gap-1 leading-none">
|
||||
<Wrench className="h-3 w-3 shrink-0" />
|
||||
{toolCallCount} tool {toolCallCount === 1 ? "use" : "uses"}
|
||||
{t("toolUseCount", { count: toolCallCount })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/components/ai-elements/message-thread"
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
buildPlanKey,
|
||||
extractLatestPlanEntriesFromMessages,
|
||||
@@ -46,7 +47,10 @@ interface ResolvedMessageGroup extends MessageGroup {
|
||||
resources: UserResourceDisplay[]
|
||||
}
|
||||
|
||||
function fallbackExtractUserResources(group: MessageGroup): {
|
||||
function fallbackExtractUserResources(
|
||||
group: MessageGroup,
|
||||
attachedResourcesText: string
|
||||
): {
|
||||
parts: AdaptedContentPart[]
|
||||
resources: UserResourceDisplay[]
|
||||
} {
|
||||
@@ -87,14 +91,17 @@ function fallbackExtractUserResources(group: MessageGroup): {
|
||||
}
|
||||
|
||||
if (parsedParts.length === 0 && dedupedResources.length > 0) {
|
||||
parsedParts.push({ type: "text", text: "Attached resources" })
|
||||
parsedParts.push({ type: "text", text: attachedResourcesText })
|
||||
}
|
||||
|
||||
return { parts: parsedParts, resources: dedupedResources }
|
||||
}
|
||||
|
||||
function resolveMessageGroup(group: MessageGroup): ResolvedMessageGroup {
|
||||
const resolved = fallbackExtractUserResources(group)
|
||||
function resolveMessageGroup(
|
||||
group: MessageGroup,
|
||||
attachedResourcesText: string
|
||||
): ResolvedMessageGroup {
|
||||
const resolved = fallbackExtractUserResources(group, attachedResourcesText)
|
||||
return {
|
||||
...group,
|
||||
parts: resolved.parts,
|
||||
@@ -161,6 +168,7 @@ export function MessageListView({
|
||||
onPendingClear,
|
||||
isActive = true,
|
||||
}: MessageListViewProps) {
|
||||
const t = useTranslations("Folder.chat.messageList")
|
||||
const { detail, loading, error, refetch } = useDbMessageDetail(conversationId)
|
||||
const turnCount = detail?.turns.length ?? 0
|
||||
|
||||
@@ -225,12 +233,16 @@ export function MessageListView({
|
||||
[pendingMessages]
|
||||
)
|
||||
const resolvedGroups = useMemo(
|
||||
() => groups.map(resolveMessageGroup),
|
||||
[groups]
|
||||
() =>
|
||||
groups.map((group) => resolveMessageGroup(group, t("attachedResources"))),
|
||||
[groups, t]
|
||||
)
|
||||
const resolvedPendingGroups = useMemo(
|
||||
() => pendingGroups.map(resolveMessageGroup),
|
||||
[pendingGroups]
|
||||
() =>
|
||||
pendingGroups.map((group) =>
|
||||
resolveMessageGroup(group, t("attachedResources"))
|
||||
),
|
||||
[pendingGroups, t]
|
||||
)
|
||||
|
||||
const showLiveMessage = Boolean(
|
||||
@@ -245,7 +257,7 @@ export function MessageListView({
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
<span>{t("loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -255,7 +267,9 @@ export function MessageListView({
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-destructive text-sm">Error: {error}</p>
|
||||
<p className="text-destructive text-sm">
|
||||
{t("error", { message: error })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -276,7 +290,7 @@ export function MessageListView({
|
||||
!showLiveMessage ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No messages in this conversation.
|
||||
{t("emptyConversation")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user