feat(webui): i18n main chat — chat + threadList domains
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -29,8 +29,10 @@ import {
|
||||
summarizeActionGroup,
|
||||
} from "@/lib/actionGrouping";
|
||||
import { mergeApprovalDecision } from "@/lib/approvalDecision";
|
||||
import { formatToolLabel, toolArgumentPreview } from "@/lib/toolLabel";
|
||||
import { formatToolLabelWith, toolArgumentPreview } from "@/lib/toolLabel";
|
||||
import { autoApprovesTools, type ReviewMode } from "@/lib/reviewMode";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useToolLabels } from "@/app/hooks/useToolLabels";
|
||||
import {
|
||||
extractStringFromMessageContent,
|
||||
extractSubAgentContent,
|
||||
@@ -114,6 +116,8 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
|
||||
compactionAnchorId,
|
||||
summarizationEvent,
|
||||
}) {
|
||||
const t = useTranslations("chat");
|
||||
const toolLabels = useToolLabels();
|
||||
const [open, setOpen] = useState(() => !defaultCollapsed);
|
||||
const [expandedSubAgents, setExpandedSubAgents] = useState<
|
||||
Record<string, boolean>
|
||||
@@ -234,7 +238,7 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
|
||||
<div className="ml-2 mt-1 space-y-3 border-l border-border pl-3">
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
Input
|
||||
{t("subAgent.input")}
|
||||
</div>
|
||||
<MarkdownContent
|
||||
content={extractSubAgentContent(subAgent.input)}
|
||||
@@ -244,7 +248,7 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
|
||||
{steps.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
Steps
|
||||
{t("subAgent.steps")}
|
||||
</div>
|
||||
<SubAgentSteps
|
||||
steps={steps}
|
||||
@@ -256,7 +260,7 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
|
||||
{subAgent.output && (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
Output
|
||||
{t("subAgent.output")}
|
||||
</div>
|
||||
<MarkdownContent
|
||||
content={extractSubAgentContent(subAgent.output)}
|
||||
@@ -318,6 +322,7 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
|
||||
stream,
|
||||
subAgentSteps,
|
||||
submittedActionRequestKeys,
|
||||
t,
|
||||
toggleSubAgent,
|
||||
ui,
|
||||
]
|
||||
@@ -325,19 +330,21 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
|
||||
|
||||
const lastToolCall = summary.lastToolCall;
|
||||
const lastToolLabel = lastToolCall
|
||||
? formatToolLabel(lastToolCall.name, lastToolCall.args)
|
||||
: "Working";
|
||||
? formatToolLabelWith(toolLabels, lastToolCall.name, lastToolCall.args)
|
||||
: t("actionGroup.working");
|
||||
const lastToolPreview = lastToolCall
|
||||
? toolArgumentPreview(lastToolCall.name, lastToolCall.args)
|
||||
: "";
|
||||
const actionNoun = summary.total === 1 ? "action" : "actions";
|
||||
const primaryText = hasPendingApproval
|
||||
? "Approval required"
|
||||
? t("actionGroup.approvalRequired")
|
||||
: summary.failed > 0
|
||||
? `${summary.failed} of ${summary.total} ${actionNoun} failed`
|
||||
? t("actionGroup.failed", {
|
||||
failed: summary.failed,
|
||||
total: summary.total,
|
||||
})
|
||||
: isStreaming || summary.pending > 0 || summary.interrupted > 0
|
||||
? `Working · ${summary.total} ${actionNoun}`
|
||||
: `${summary.total} ${actionNoun} completed`;
|
||||
? t("actionGroup.workingWithCount", { total: summary.total })
|
||||
: t("actionGroup.completed", { total: summary.total });
|
||||
const headerText = `${primaryText} · ${lastToolLabel}${
|
||||
lastToolPreview ? ` · ${lastToolPreview}` : ""
|
||||
}`;
|
||||
@@ -345,12 +352,16 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
|
||||
return (
|
||||
<section
|
||||
className="my-2 overflow-hidden rounded-md border border-border bg-[var(--color-surface)]"
|
||||
aria-label="Assistant work process"
|
||||
aria-label={t("actionGroup.ariaLabel")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-label={`${open ? "Collapse" : "Expand"} ${headerText}`}
|
||||
aria-label={
|
||||
open
|
||||
? t("actionGroup.collapseGroup", { summary: headerText })
|
||||
: t("actionGroup.expandGroup", { summary: headerText })
|
||||
}
|
||||
title={headerText}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="flex min-h-10 w-full items-center gap-2 px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
@@ -446,13 +457,15 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-1 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-sm text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Collapse ${summary.total} ${actionNoun}`}
|
||||
aria-label={t("actionGroup.collapseCount", {
|
||||
total: summary.total,
|
||||
})}
|
||||
>
|
||||
<ChevronUp
|
||||
aria-hidden="true"
|
||||
className="size-3.5"
|
||||
/>
|
||||
Collapse
|
||||
{t("actionGroup.collapse")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MessageCircleQuestion } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Choice {
|
||||
value: string;
|
||||
@@ -32,6 +33,8 @@ export function AskUserInterrupt({
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: AskUserInterruptProps) {
|
||||
const t = useTranslations("chat");
|
||||
const tCommon = useTranslations("common");
|
||||
// answers[i] = final answer string; picked[i] = selected choice value or
|
||||
// OTHER for multiple_choice (null for free-text questions).
|
||||
const [answers, setAnswers] = useState<string[]>(() =>
|
||||
@@ -62,7 +65,7 @@ export function AskUserInterrupt({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
EvoScientist Needs Your Input
|
||||
{t("askUser.title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -114,13 +117,13 @@ export function AskUserInterrupt({
|
||||
: "border-border bg-background hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
Other…
|
||||
{t("askUser.other")}
|
||||
</button>
|
||||
{picked[i] === OTHER && (
|
||||
<Textarea
|
||||
value={answers[i]}
|
||||
onChange={(e) => setAnswer(i, e.target.value)}
|
||||
placeholder="Type your answer…"
|
||||
placeholder={t("askUser.answerPlaceholder")}
|
||||
className="text-sm"
|
||||
rows={2}
|
||||
disabled={isLoading}
|
||||
@@ -133,8 +136,8 @@ export function AskUserInterrupt({
|
||||
onChange={(e) => setAnswer(i, e.target.value)}
|
||||
placeholder={
|
||||
isOptional(q)
|
||||
? "Type your answer… (optional)"
|
||||
: "Type your answer…"
|
||||
? t("askUser.answerPlaceholderOptional")
|
||||
: t("askUser.answerPlaceholder")
|
||||
}
|
||||
className="text-sm"
|
||||
rows={2}
|
||||
@@ -152,7 +155,7 @@ export function AskUserInterrupt({
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -160,7 +163,7 @@ export function AskUserInterrupt({
|
||||
disabled={!canSubmit || isLoading}
|
||||
className="bg-[var(--brand-solid)] text-[var(--brand-foreground)] hover:opacity-90"
|
||||
>
|
||||
{isLoading ? "Submitting…" : "Submit"}
|
||||
{isLoading ? t("askUser.submitting") : t("askUser.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
} from "@/app/hooks/useAvailableModels";
|
||||
import { formatTokenCount, useUsage } from "@/app/hooks/useUsage";
|
||||
import { UsageDetailsDialog } from "@/app/components/UsageDetailsDialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type DashboardNavTarget =
|
||||
| {
|
||||
@@ -142,44 +143,6 @@ interface ChatInterfaceProps {
|
||||
onNotifyReady?: (notify: MainChatReporter | null) => void;
|
||||
}
|
||||
|
||||
const SUGGESTED_PROMPTS = [
|
||||
"Survey recent papers on a topic",
|
||||
"Design an experiment plan",
|
||||
"Analyze workspace files",
|
||||
];
|
||||
|
||||
const REVIEW_MODE_OPTIONS: Array<{
|
||||
mode: ReviewMode;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Hand;
|
||||
activeClassName: string;
|
||||
}> = [
|
||||
{
|
||||
mode: "manual",
|
||||
label: "Manual review",
|
||||
description: "Approve each tool action before it runs.",
|
||||
icon: Hand,
|
||||
activeClassName: "bg-[var(--brand-solid)] text-[var(--brand-foreground)]",
|
||||
},
|
||||
{
|
||||
mode: "auto",
|
||||
label: "Auto-approve",
|
||||
description:
|
||||
"Approve tool actions automatically; questions can still pause.",
|
||||
icon: Zap,
|
||||
activeClassName: "bg-amber-600 text-white hover:bg-amber-700",
|
||||
},
|
||||
{
|
||||
mode: "full",
|
||||
label: "Full approve",
|
||||
description:
|
||||
"Approve tool actions automatically and continue without questions.",
|
||||
icon: ChevronsRight,
|
||||
activeClassName: "bg-rose-700 text-white hover:bg-rose-800",
|
||||
},
|
||||
];
|
||||
|
||||
interface UploadedWorkspaceFile {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -380,6 +343,43 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
onOpenThread,
|
||||
workspaceOpen,
|
||||
}) => {
|
||||
const t = useTranslations("chat");
|
||||
const tCommon = useTranslations("common");
|
||||
const suggestedPrompts = [
|
||||
t("suggestedSurvey"),
|
||||
t("suggestedExperiment"),
|
||||
t("suggestedFiles"),
|
||||
];
|
||||
const reviewModeOptions: Array<{
|
||||
mode: ReviewMode;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Hand;
|
||||
activeClassName: string;
|
||||
}> = [
|
||||
{
|
||||
mode: "manual",
|
||||
label: t("reviewMode.manual.label"),
|
||||
description: t("reviewMode.manual.description"),
|
||||
icon: Hand,
|
||||
activeClassName:
|
||||
"bg-[var(--brand-solid)] text-[var(--brand-foreground)]",
|
||||
},
|
||||
{
|
||||
mode: "auto",
|
||||
label: t("reviewMode.auto.label"),
|
||||
description: t("reviewMode.auto.description"),
|
||||
icon: Zap,
|
||||
activeClassName: "bg-amber-600 text-white hover:bg-amber-700",
|
||||
},
|
||||
{
|
||||
mode: "full",
|
||||
label: t("reviewMode.full.label"),
|
||||
description: t("reviewMode.full.description"),
|
||||
icon: ChevronsRight,
|
||||
activeClassName: "bg-rose-700 text-white hover:bg-rose-800",
|
||||
},
|
||||
];
|
||||
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
|
||||
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -522,10 +522,12 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
usage.status.availability === "offline" ||
|
||||
usage.status.availability === "unavailable";
|
||||
const usageLabel = usage.loading
|
||||
? "Tokens loading"
|
||||
? t("usage.tokensLoading")
|
||||
: usageUnavailable || !usage.summary
|
||||
? "Tokens unavailable"
|
||||
: `${formatTokenCount(usage.summary.total_tokens)} tokens`;
|
||||
? t("usage.tokensUnavailable")
|
||||
: t("usage.tokensLabel", {
|
||||
count: formatTokenCount(usage.summary.total_tokens),
|
||||
});
|
||||
|
||||
// Count of background async sub-agents (writing / data-analysis) still
|
||||
// running — drives the composer's "agents running" pulse. We poll each task's
|
||||
@@ -617,9 +619,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
// the user's own queue has drained.
|
||||
if (queuedMessages.length > 0) return;
|
||||
const reportedKeys = getThreadAutoNotifyReportedKeys(threadId);
|
||||
for (const t of liveAgentTasks) {
|
||||
if (!isTerminalStatus(t.liveStatus)) continue;
|
||||
const key = asyncTaskReportKey(t);
|
||||
for (const task of liveAgentTasks) {
|
||||
if (!isTerminalStatus(task.liveStatus)) continue;
|
||||
const key = asyncTaskReportKey(task);
|
||||
if (reportedKeys.has(key)) continue;
|
||||
if (
|
||||
messages.some(
|
||||
@@ -627,7 +629,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
message.type === "human" &&
|
||||
asyncUpdateMatchesTask(
|
||||
extractStringFromMessageContent(message),
|
||||
t
|
||||
task
|
||||
)
|
||||
)
|
||||
) {
|
||||
@@ -636,9 +638,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
}
|
||||
autoFireInFlightRef.current = true;
|
||||
markThreadAutoNotifyReported(threadId, key);
|
||||
sendMessage(formatAsyncUpdateMessage(t), reviewMode);
|
||||
sendMessage(formatAsyncUpdateMessage(task), reviewMode);
|
||||
toast.success(
|
||||
`Auto-reported ${agentLabel(t.agent_name)} to the main chat.`
|
||||
t("agents.autoReported", { agent: agentLabel(task.agent_name) })
|
||||
);
|
||||
break; // one per idle window; the rest fire once this turn settles
|
||||
}
|
||||
@@ -649,6 +651,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
input,
|
||||
messages,
|
||||
sendMessage,
|
||||
t,
|
||||
threadId,
|
||||
queuedMessages,
|
||||
reviewMode,
|
||||
@@ -953,17 +956,17 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
applyReviewMode(mode);
|
||||
toast.info(
|
||||
mode === "auto"
|
||||
? "Auto-approve enabled for this chat."
|
||||
: "Manual review enabled for this chat."
|
||||
? t("reviewMode.autoEnabled")
|
||||
: t("reviewMode.manualEnabled")
|
||||
);
|
||||
},
|
||||
[applyReviewMode, reviewMode, reviewModeLocked]
|
||||
[applyReviewMode, reviewMode, reviewModeLocked, t]
|
||||
);
|
||||
|
||||
const confirmFullApprove = useCallback(() => {
|
||||
applyReviewMode("full");
|
||||
toast.info("Full approve enabled for this chat.");
|
||||
}, [applyReviewMode]);
|
||||
toast.info(t("reviewMode.fullEnabled"));
|
||||
}, [applyReviewMode, t]);
|
||||
|
||||
// Follow the thread and migrate the pending New Chat mode only when the first
|
||||
// submitted message created this thread.
|
||||
@@ -1033,21 +1036,17 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
}
|
||||
setPendingFiles((currentFiles) => [...currentFiles, ...data.files!]);
|
||||
notifyWorkspaceChanged(uploadThreadId);
|
||||
toast.success(
|
||||
`${data.files.length} file${
|
||||
data.files.length === 1 ? "" : "s"
|
||||
} uploaded to the workspace.`
|
||||
);
|
||||
toast.success(t("upload.uploaded", { count: data.files.length }));
|
||||
} catch (error) {
|
||||
errorToast(
|
||||
"chat.attachment",
|
||||
error instanceof Error ? error.message : "Failed to upload files."
|
||||
error instanceof Error ? error.message : t("upload.failed")
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingFiles(false);
|
||||
}
|
||||
},
|
||||
[ensureThread, threadId]
|
||||
[ensureThread, t, threadId]
|
||||
);
|
||||
|
||||
const removePendingFile = useCallback((filePath: string) => {
|
||||
@@ -1139,10 +1138,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
if (!target || messages[0]?.id === id) return messages;
|
||||
return [target, ...messages.filter((message) => message.id !== id)];
|
||||
});
|
||||
toast.info(
|
||||
"This message will run next without interrupting the current turn."
|
||||
);
|
||||
}, []);
|
||||
toast.info(t("queue.steerToast"));
|
||||
}, [t]);
|
||||
|
||||
const moveQueuedMessage = useCallback((id: number, direction: -1 | 1) => {
|
||||
setQueuedMessages((messages) => {
|
||||
@@ -1536,10 +1533,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Full approve?</DialogTitle>
|
||||
<DialogTitle>{t("reviewMode.fullApprove.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
EvoScientist will approve reviewed tool actions automatically
|
||||
and will not pause to ask you for clarification.
|
||||
{t("reviewMode.fullApprove.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
@@ -1547,24 +1543,20 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>
|
||||
When details are missing, the model will make reasonable,
|
||||
conservative assumptions and continue. This setting applies only
|
||||
to the current research.
|
||||
</p>
|
||||
<p>{t("reviewMode.fullApprove.warning")}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setFullApproveDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmFullApprove}
|
||||
className="bg-amber-600 text-white hover:bg-amber-700"
|
||||
>
|
||||
Enable Full approve
|
||||
{t("reviewMode.fullApprove.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1593,28 +1585,31 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Choose model</DialogTitle>
|
||||
<DialogTitle>{t("modelPicker.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Saved for this chat; the next message freezes it into the
|
||||
run’s snapshot.
|
||||
{t("modelPicker.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current:{" "}
|
||||
{t("modelPicker.current")}{" "}
|
||||
<span className="font-mono">
|
||||
{modelSelection !== "inherit"
|
||||
? modelRefLabel(modelSelection.primary)
|
||||
: currentModel
|
||||
? `${currentModel.name}${
|
||||
currentModel.provider
|
||||
? ` (${currentModel.provider})`
|
||||
: ""
|
||||
} · default`
|
||||
: "registry default"}
|
||||
? t("modelPicker.currentDefault", {
|
||||
model: `${currentModel.name}${
|
||||
currentModel.provider
|
||||
? ` (${currentModel.provider})`
|
||||
: ""
|
||||
}`,
|
||||
})
|
||||
: t("modelPicker.registryDefault")}
|
||||
</span>
|
||||
{modelRegistryLoading && (
|
||||
<span className="ml-2 italic">loading models…</span>
|
||||
<span className="ml-2 italic">
|
||||
{t("modelPicker.loadingModels")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{modelRegistryUnavailable && (
|
||||
@@ -1626,8 +1621,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
{"\u26A0"}
|
||||
</span>
|
||||
<span>
|
||||
The deployment’s selectable model list didn’t
|
||||
load
|
||||
{t("modelPicker.unavailable")}
|
||||
{modelRegistryError ? ` (${modelRegistryError})` : ""}.
|
||||
</span>
|
||||
</div>
|
||||
@@ -1636,7 +1630,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
type="text"
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
placeholder="Filter by name or provider…"
|
||||
placeholder={t("modelPicker.filterPlaceholder")}
|
||||
autoFocus
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-1.5 font-mono text-sm placeholder:font-sans placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
@@ -1644,8 +1638,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
{filteredPickerModels.length === 0 && (
|
||||
<li className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{modelSearch
|
||||
? `No models match "${modelSearch}".`
|
||||
: "No models are enabled."}
|
||||
? t("modelPicker.noMatch", { query: modelSearch })
|
||||
: t("modelPicker.noneEnabled")}
|
||||
</li>
|
||||
)}
|
||||
{filteredPickerModels.map((m) => {
|
||||
@@ -1674,14 +1668,18 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
await setModelSelection({
|
||||
primary: ref,
|
||||
});
|
||||
toast.success(`Model set to ${modelKey}.`);
|
||||
toast.success(
|
||||
t("modelPicker.setSuccess", { model: modelKey })
|
||||
);
|
||||
setModelPickerOpen(false);
|
||||
} catch (err) {
|
||||
errorToast(
|
||||
"chat.message",
|
||||
err instanceof Error
|
||||
? `Couldn't update model: ${err.message}`
|
||||
: "Couldn't update model — try again."
|
||||
? t("modelPicker.updateFailed", {
|
||||
message: err.message,
|
||||
})
|
||||
: t("modelPicker.updateFailedGeneric")
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -1707,22 +1705,26 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
onClick={async () => {
|
||||
try {
|
||||
await setModelSelection("inherit");
|
||||
toast.success("Model reset to the registry default.");
|
||||
toast.success(t("modelPicker.resetSuccess"));
|
||||
setModelPickerOpen(false);
|
||||
} catch (err) {
|
||||
errorToast(
|
||||
"chat.message",
|
||||
err instanceof Error
|
||||
? `Couldn't reset model: ${err.message}`
|
||||
: "Couldn't reset model — try again."
|
||||
? t("modelPicker.resetFailed", {
|
||||
message: err.message,
|
||||
})
|
||||
: t("modelPicker.resetFailedGeneric")
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={modelSelection === "inherit"}
|
||||
>
|
||||
Reset to default
|
||||
{t("modelPicker.resetToDefault")}
|
||||
</Button>
|
||||
<Button onClick={() => setModelPickerOpen(false)}>
|
||||
{tCommon("close")}
|
||||
</Button>
|
||||
<Button onClick={() => setModelPickerOpen(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1736,7 +1738,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
>
|
||||
{isThreadLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<p className="text-muted-foreground">Loading…</p>
|
||||
<p className="text-muted-foreground">{tCommon("loading")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -1749,14 +1751,13 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-pretty text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Where research evolves
|
||||
{t("emptyStateTitle")}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-lg text-sm text-muted-foreground">
|
||||
Your self-evolving lab partner — reads the literature,
|
||||
runs experiments, and remembers what matters.
|
||||
{t("emptyStateSubtitle")}
|
||||
</p>
|
||||
<div className="mt-4 flex max-w-2xl flex-wrap justify-center gap-2">
|
||||
{SUGGESTED_PROMPTS.map((prompt) => (
|
||||
{suggestedPrompts.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
type="button"
|
||||
@@ -1891,7 +1892,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
<div className="flex-shrink-0 bg-background">
|
||||
{queuedMessages.length > 0 && (
|
||||
<div
|
||||
aria-label="Queued messages"
|
||||
aria-label={t("queue.label")}
|
||||
className="relative mx-auto -mb-4 w-[calc(100%-16px)] max-w-[960px] px-2 sm:w-[calc(100%-24px)]"
|
||||
>
|
||||
<div className="rounded-b-lg rounded-t-2xl border border-border bg-background pb-5 pt-1 shadow-sm">
|
||||
@@ -1932,10 +1933,11 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
moveQueuedMessage(q.id, 1);
|
||||
}
|
||||
}}
|
||||
aria-label={`Reorder queued message ${index + 1} of ${
|
||||
queuedMessages.length
|
||||
}`}
|
||||
title="Drag to reorder, or use the arrow keys"
|
||||
aria-label={t("queue.reorderAria", {
|
||||
index: index + 1,
|
||||
total: queuedMessages.length,
|
||||
})}
|
||||
title={t("queue.reorderTitle")}
|
||||
className="mt-0.5 inline-flex size-5 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground/60 focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical
|
||||
@@ -1957,8 +1959,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
className="size-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{q.files.length} file
|
||||
{q.files.length === 1 ? "" : "s"}
|
||||
{t("queue.filesAttached", {
|
||||
count: q.files.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1966,21 +1969,23 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => steerQueuedMessage(q.id)}
|
||||
aria-label="Steer with this message next"
|
||||
title="Submit next without interrupting the current turn"
|
||||
aria-label={t("queue.steerAria")}
|
||||
title={t("queue.steerTitle")}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-full px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<CornerDownRight
|
||||
className="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden sm:inline">Steer</span>
|
||||
<span className="hidden sm:inline">
|
||||
{t("queue.steer")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeQueuedMessage(q.id)}
|
||||
aria-label="Remove queued message"
|
||||
title="Remove from queue"
|
||||
aria-label={t("queue.removeAria")}
|
||||
title={t("queue.removeTitle")}
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Trash2
|
||||
@@ -2007,8 +2012,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
aria-label="More queued message actions"
|
||||
title="More actions"
|
||||
aria-label={t("queue.moreAria")}
|
||||
title={t("queue.moreTitle")}
|
||||
className="inline-flex size-7 cursor-pointer list-none items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<Ellipsis
|
||||
@@ -2027,7 +2032,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Edit message
|
||||
{t("editMessage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2038,7 +2043,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Close queue
|
||||
{t("queue.closeQueue")}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
@@ -2093,7 +2098,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
All tasks completed
|
||||
{t("tasks.allCompleted")}
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
@@ -2107,9 +2112,12 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
Task{" "}
|
||||
{totalTasks - groupedTodos.pending.length}{" "}
|
||||
of {totalTasks}
|
||||
{t("tasks.progress", {
|
||||
current:
|
||||
totalTasks -
|
||||
groupedTodos.pending.length,
|
||||
total: totalTasks,
|
||||
})}
|
||||
</span>,
|
||||
<span
|
||||
key="content"
|
||||
@@ -2130,9 +2138,11 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
Task{" "}
|
||||
{totalTasks - groupedTodos.pending.length} of{" "}
|
||||
{totalTasks}
|
||||
{t("tasks.progress", {
|
||||
current:
|
||||
totalTasks - groupedTodos.pending.length,
|
||||
total: totalTasks,
|
||||
})}
|
||||
</span>,
|
||||
];
|
||||
})()}
|
||||
@@ -2154,7 +2164,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
aria-expanded={metaOpen === "files"}
|
||||
>
|
||||
<FileIcon size={16} />
|
||||
Files (State)
|
||||
{t("tasks.filesState")}
|
||||
<span className="h-4 min-w-4 rounded-full bg-[var(--brand-solid)] px-0.5 text-center text-[10px] leading-[16px] text-[var(--brand-foreground)]">
|
||||
{Object.keys(files).length}
|
||||
</span>
|
||||
@@ -2188,7 +2198,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
}
|
||||
aria-expanded={metaOpen === "tasks"}
|
||||
>
|
||||
Tasks
|
||||
{t("tasks.tasks")}
|
||||
</button>
|
||||
)}
|
||||
{hasFiles && (
|
||||
@@ -2202,14 +2212,14 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
}
|
||||
aria-expanded={metaOpen === "files"}
|
||||
>
|
||||
Files (State)
|
||||
{t("tasks.filesState")}
|
||||
<span className="h-4 min-w-4 rounded-full bg-[var(--brand-solid)] px-0.5 text-center text-[10px] leading-[16px] text-[var(--brand-foreground)]">
|
||||
{Object.keys(files).length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Close"
|
||||
aria-label={tCommon("close")}
|
||||
className="flex-1"
|
||||
onClick={() => setMetaOpen(null)}
|
||||
/>
|
||||
@@ -2229,9 +2239,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||
{
|
||||
{
|
||||
pending: "Pending",
|
||||
in_progress: "In Progress",
|
||||
completed: "Completed",
|
||||
pending: t("tasks.pending"),
|
||||
in_progress: t("tasks.inProgress"),
|
||||
completed: t("tasks.completed"),
|
||||
}[status]
|
||||
}
|
||||
</h3>
|
||||
@@ -2272,8 +2282,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
title="Click to change model for this chat"
|
||||
aria-label="Change model for this chat"
|
||||
title={t("modelPicker.changeTitle")}
|
||||
aria-label={t("modelPicker.changeAria")}
|
||||
className="-mx-1 flex items-center gap-1.5 rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Sparkles
|
||||
@@ -2305,15 +2315,15 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
});
|
||||
toast.success(
|
||||
effort === null
|
||||
? "Reasoning effort back to the registry default."
|
||||
: `Reasoning effort set to ${effort}.`
|
||||
? t("reasoning.reset")
|
||||
: t("reasoning.set", { effort })
|
||||
);
|
||||
} catch (err) {
|
||||
errorToast(
|
||||
"chat.model",
|
||||
err instanceof Error
|
||||
? `Couldn't update reasoning effort: ${err.message}`
|
||||
: "Couldn't update reasoning effort — try again."
|
||||
? t("reasoning.failed", { message: err.message })
|
||||
: t("reasoning.failedGeneric")
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -2340,17 +2350,21 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
});
|
||||
toast.success(
|
||||
value === null
|
||||
? "Sampling back to the registry default."
|
||||
? t("sampling.reset")
|
||||
: value.kind === "temperature"
|
||||
? `Temperature set to ${value.value.toFixed(2)} (top_p omitted).`
|
||||
: `Top P set to ${value.value.toFixed(2)} (temperature omitted).`
|
||||
? t("sampling.temperatureSet", {
|
||||
value: value.value.toFixed(2),
|
||||
})
|
||||
: t("sampling.topPSet", {
|
||||
value: value.value.toFixed(2),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
errorToast(
|
||||
"chat.model",
|
||||
err instanceof Error
|
||||
? `Couldn't update sampling: ${err.message}`
|
||||
: "Couldn't update sampling — try again."
|
||||
? t("sampling.failed", { message: err.message })
|
||||
: t("sampling.failedGeneric")
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -2367,7 +2381,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
type="button"
|
||||
onClick={() => setUsageDialogOpen(true)}
|
||||
title={usageLabel}
|
||||
aria-label={`View token usage: ${usageLabel}`}
|
||||
aria-label={t("usage.viewAria", { label: usageLabel })}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5 transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
currentModel && "ml-auto",
|
||||
@@ -2387,19 +2401,19 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowAgents}
|
||||
title={`${runningAgents} background agent${
|
||||
runningAgents === 1 ? "" : "s"
|
||||
} running — click to view`}
|
||||
aria-label={`${runningAgents} background agent${
|
||||
runningAgents === 1 ? "" : "s"
|
||||
} running — view`}
|
||||
title={t("agents.runningTitle", {
|
||||
count: runningAgents,
|
||||
})}
|
||||
aria-label={t("agents.runningAria", {
|
||||
count: runningAgents,
|
||||
})}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5 font-medium text-[var(--brand)] transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span
|
||||
className="size-2 animate-pulse rounded-full bg-[var(--color-warning)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{runningAgents} agent{runningAgents === 1 ? "" : "s"}
|
||||
{t("agents.runningLabel", { count: runningAgents })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -2410,7 +2424,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
>
|
||||
{pendingFiles.length > 0 && (
|
||||
<div
|
||||
aria-label="Attached files"
|
||||
aria-label={t("upload.attachedFiles")}
|
||||
className="flex flex-wrap gap-2 border-b border-border px-3 py-2"
|
||||
>
|
||||
{pendingFiles.map((file) => (
|
||||
@@ -2431,8 +2445,12 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePendingFile(file.path)}
|
||||
aria-label={`Remove ${file.name} from this message`}
|
||||
title={`Remove ${file.name} from this message`}
|
||||
aria-label={t("upload.removeAttachment", {
|
||||
name: file.name,
|
||||
})}
|
||||
title={t("upload.removeAttachment", {
|
||||
name: file.name,
|
||||
})}
|
||||
className="rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X
|
||||
@@ -2449,16 +2467,16 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label="Message"
|
||||
aria-label={t("messageInputLabel")}
|
||||
disabled={hasPendingInterrupt}
|
||||
placeholder={
|
||||
hasPendingInterrupt
|
||||
? "Respond to the request above to continue…"
|
||||
? t("placeholderRespondToInterrupt")
|
||||
: isReconnecting
|
||||
? "Reconnecting to the running task…"
|
||||
? t("placeholderReconnecting")
|
||||
: isLoading
|
||||
? "Queue a follow-up — sends when this turn finishes…"
|
||||
: "Ask EvoScientist anything…"
|
||||
? t("placeholderQueueFollowUp")
|
||||
: t("placeholderDefault")
|
||||
}
|
||||
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-3.5 pb-2.5 pt-3 text-sm leading-6 text-primary outline-none placeholder:text-tertiary disabled:cursor-not-allowed sm:px-4"
|
||||
rows={1}
|
||||
@@ -2481,8 +2499,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
disabled={
|
||||
!assistant || hasPendingInterrupt || isUploadingFiles
|
||||
}
|
||||
aria-label="Upload files to workspace"
|
||||
title="Upload files to workspace (max 50 MB each)"
|
||||
aria-label={t("upload.aria")}
|
||||
title={t("upload.title")}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Paperclip
|
||||
@@ -2492,10 +2510,10 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
</button>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Review mode"
|
||||
aria-label={t("reviewMode.label")}
|
||||
className="inline-flex h-8 items-center rounded-md border border-border bg-muted/40 p-0.5"
|
||||
>
|
||||
{REVIEW_MODE_OPTIONS.map((option) => {
|
||||
{reviewModeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = reviewMode === option.mode;
|
||||
return (
|
||||
@@ -2527,12 +2545,16 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate({ view: "workspace" })}
|
||||
title={`${
|
||||
workspaceOpen ? "Close" : "Open"
|
||||
} conversation files`}
|
||||
aria-label={`${
|
||||
workspaceOpen ? "Close" : "Open"
|
||||
} conversation files`}
|
||||
title={
|
||||
workspaceOpen
|
||||
? t("workspace.closeFiles")
|
||||
: t("workspace.openFiles")
|
||||
}
|
||||
aria-label={
|
||||
workspaceOpen
|
||||
? t("workspace.closeFiles")
|
||||
: t("workspace.openFiles")
|
||||
}
|
||||
aria-pressed={Boolean(workspaceOpen)}
|
||||
className="inline-flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
@@ -2541,7 +2563,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden max-w-[140px] truncate font-mono sm:inline lg:max-w-[220px]">
|
||||
Files
|
||||
{t("workspace.files")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -2555,17 +2577,19 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
!isLoading &&
|
||||
(submitDisabled || isUploadingFiles || !input.trim())
|
||||
}
|
||||
aria-label={isLoading ? "Stop generating" : "Send message"}
|
||||
aria-label={
|
||||
isLoading ? t("stopGenerating") : t("sendMessage")
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Square size={14} />
|
||||
<span className="hidden sm:inline">Stop</span>
|
||||
<span className="hidden sm:inline">{t("stop")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUp size={18} />
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
<span className="hidden sm:inline">{t("send")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -2597,6 +2621,13 @@ function ReasoningEffortSlider({
|
||||
value: ReasoningEffort | null;
|
||||
onCommit: (effort: ReasoningEffort | null) => void | Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations("chat");
|
||||
const effortLabels: Record<ReasoningEffort, string> = {
|
||||
auto: t("reasoning.auto"),
|
||||
low: t("reasoning.low"),
|
||||
medium: t("reasoning.medium"),
|
||||
high: t("reasoning.high"),
|
||||
};
|
||||
const committedIndex =
|
||||
value && value !== "auto" ? EFFORT_LEVELS.indexOf(value) : 0;
|
||||
const [draft, setDraft] = useState<number | null>(null);
|
||||
@@ -2614,15 +2645,13 @@ function ReasoningEffortSlider({
|
||||
<span
|
||||
className={cn("flex items-center gap-1.5", disabled && "opacity-50")}
|
||||
title={
|
||||
disabled
|
||||
? "Pick a model to override the registry-configured effort"
|
||||
: "Reasoning effort for the primary model"
|
||||
disabled ? t("reasoning.titleDisabled") : t("reasoning.title")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Reasoning effort"
|
||||
aria-valuetext={shown}
|
||||
aria-label={t("reasoning.aria")}
|
||||
aria-valuetext={effortLabels[shown]}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={EFFORT_LEVELS.length - 1}
|
||||
@@ -2652,7 +2681,7 @@ function ReasoningEffortSlider({
|
||||
: "font-medium text-foreground"
|
||||
)}
|
||||
>
|
||||
{shown}
|
||||
{effortLabels[shown]}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
@@ -2679,6 +2708,7 @@ function GenerationParamSlider({
|
||||
step: number;
|
||||
onCommit: (value: number | null) => void | Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations("chat");
|
||||
const anchor = override ?? fallback ?? min;
|
||||
const [draft, setDraft] = useState<number | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -2725,10 +2755,13 @@ function GenerationParamSlider({
|
||||
)}
|
||||
title={
|
||||
disabled
|
||||
? `Pick a model to override the registry-configured ${ariaLabel}`
|
||||
? t("sampling.sliderTitleDisabled", { label: ariaLabel })
|
||||
: override === null
|
||||
? `${ariaLabel}: registry default ${fallback ?? "unset"} — drag to override or click the value to type one`
|
||||
: `${ariaLabel} override — click the value to type an exact one; empty + Enter resets`
|
||||
? t("sampling.sliderTitleDefault", {
|
||||
label: ariaLabel,
|
||||
value: fallback ?? t("sampling.unset"),
|
||||
})
|
||||
: t("sampling.sliderTitleOverride", { label: ariaLabel })
|
||||
}
|
||||
>
|
||||
<input
|
||||
@@ -2760,7 +2793,7 @@ function GenerationParamSlider({
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
aria-label={`${ariaLabel} exact value`}
|
||||
aria-label={t("sampling.exactValue", { label: ariaLabel })}
|
||||
autoFocus
|
||||
value={editText}
|
||||
onChange={(event) => setEditText(event.target.value)}
|
||||
@@ -2782,7 +2815,7 @@ function GenerationParamSlider({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={startEdit}
|
||||
title={`Click to type an exact ${ariaLabel} value`}
|
||||
title={t("sampling.clickToType", { label: ariaLabel })}
|
||||
className={cn(
|
||||
"w-12 text-left text-xs tabular-nums",
|
||||
isOverride
|
||||
@@ -2812,6 +2845,7 @@ function SamplingOverridePicker({
|
||||
fallbackTopP: number | null;
|
||||
onCommit: (value: SamplingOverride | null) => void | Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations("chat");
|
||||
const mode: "default" | "temperature" | "top_p" = override?.kind ?? "default";
|
||||
|
||||
const select = (next: "default" | "temperature" | "top_p") => {
|
||||
@@ -2832,7 +2866,7 @@ function SamplingOverridePicker({
|
||||
};
|
||||
|
||||
const modes = [
|
||||
{ key: "default", label: "默认" },
|
||||
{ key: "default", label: t("sampling.modeDefault") },
|
||||
{ key: "temperature", label: "T" },
|
||||
{ key: "top_p", label: "P" },
|
||||
] as const;
|
||||
@@ -2841,9 +2875,7 @@ function SamplingOverridePicker({
|
||||
<span
|
||||
className={cn("flex items-center gap-1.5", disabled && "opacity-50")}
|
||||
title={
|
||||
disabled
|
||||
? "Pick a model to override the registry-configured sampling"
|
||||
: "Sampling override: temperature and top_p are mutually exclusive"
|
||||
disabled ? t("sampling.titleDisabled") : t("sampling.title")
|
||||
}
|
||||
>
|
||||
{/* ≥sm: segmented control */}
|
||||
@@ -2868,7 +2900,7 @@ function SamplingOverridePicker({
|
||||
</span>
|
||||
{/* <sm: dropdown */}
|
||||
<select
|
||||
aria-label="Sampling override"
|
||||
aria-label={t("sampling.aria")}
|
||||
disabled={disabled}
|
||||
value={mode}
|
||||
onChange={(event) =>
|
||||
@@ -2884,7 +2916,7 @@ function SamplingOverridePicker({
|
||||
</select>
|
||||
{mode === "temperature" && (
|
||||
<GenerationParamSlider
|
||||
ariaLabel="Temperature"
|
||||
ariaLabel={t("sampling.temperature")}
|
||||
disabled={disabled}
|
||||
override={override?.kind === "temperature" ? override.value : null}
|
||||
fallback={fallbackTemperature}
|
||||
@@ -2898,7 +2930,7 @@ function SamplingOverridePicker({
|
||||
)}
|
||||
{mode === "top_p" && (
|
||||
<GenerationParamSlider
|
||||
ariaLabel="Top P"
|
||||
ariaLabel={t("sampling.topP")}
|
||||
disabled={disabled}
|
||||
override={override?.kind === "top_p" ? override.value : null}
|
||||
fallback={fallbackTopP}
|
||||
@@ -2915,6 +2947,7 @@ function SamplingOverridePicker({
|
||||
}
|
||||
|
||||
function ThinkingTimer() {
|
||||
const t = useTranslations("chat");
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
@@ -2935,7 +2968,7 @@ function ThinkingTimer() {
|
||||
aria-hidden="true"
|
||||
className="size-2 animate-pulse rounded-full bg-[var(--brand)]"
|
||||
/>
|
||||
<span>思考中…</span>
|
||||
<span>{t("thinkingTimer")}</span>
|
||||
<span className="tabular-nums">
|
||||
{mm}:{ss}
|
||||
</span>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { mapActionRequestsToToolCalls } from "@/lib/actionGrouping";
|
||||
import { mergeApprovalDecision } from "@/lib/approvalDecision";
|
||||
import { autoApprovesTools, type ReviewMode } from "@/lib/reviewMode";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
@@ -75,6 +76,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
subAgentSteps,
|
||||
threadId,
|
||||
}) => {
|
||||
const t = useTranslations("chat");
|
||||
const isUser = message.type === "human";
|
||||
const messageContent = extractStringFromMessageContent(message);
|
||||
const hasContent = messageContent && messageContent.trim() !== "";
|
||||
@@ -185,9 +187,9 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
|
||||
copyResetTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
errorToast("clipboard", "Couldn't copy to clipboard.");
|
||||
errorToast("clipboard", t("message.copyFailed"));
|
||||
}
|
||||
}, [messageContent]);
|
||||
}, [messageContent, t]);
|
||||
const [expandedSubAgents, setExpandedSubAgents] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
@@ -218,7 +220,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
className="size-3 text-[var(--brand)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Background agent reported back
|
||||
{t("message.backgroundAgentReported")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -256,7 +258,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Thinking
|
||||
{t("message.thinking")}
|
||||
</button>
|
||||
{thinkingOpen && (
|
||||
<div className="mt-2 whitespace-pre-wrap break-words border-l-2 border-border pl-3 text-sm leading-relaxed text-muted-foreground">
|
||||
@@ -299,7 +301,9 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied" : "Copy message"}
|
||||
aria-label={
|
||||
copied ? t("message.copied") : t("message.copy")
|
||||
}
|
||||
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{copied ? (
|
||||
@@ -321,7 +325,9 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied" : "Copy message"}
|
||||
aria-label={
|
||||
copied ? t("message.copied") : t("message.copy")
|
||||
}
|
||||
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{copied ? (
|
||||
@@ -339,7 +345,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditMessage?.(messageContent)}
|
||||
aria-label="Edit message"
|
||||
aria-label={t("editMessage")}
|
||||
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Pencil
|
||||
@@ -421,7 +427,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
<div className="w-full max-w-full">
|
||||
<div className="border-border-light rounded-md border bg-[var(--color-surface)] p-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
|
||||
Input
|
||||
{t("subAgent.input")}
|
||||
</h4>
|
||||
<div className="mb-4">
|
||||
<MarkdownContent
|
||||
@@ -432,7 +438,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
{(subAgentSteps?.[subAgent.id]?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
|
||||
Steps
|
||||
{t("subAgent.steps")}
|
||||
</h4>
|
||||
<div className="mb-4">
|
||||
<SubAgentSteps
|
||||
@@ -445,7 +451,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
{subAgent.output && (
|
||||
<>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
|
||||
Output
|
||||
{t("subAgent.output")}
|
||||
</h4>
|
||||
<MarkdownContent
|
||||
content={extractSubAgentContent(subAgent.output)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronRight, Layers } from "lucide-react";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import { extractSummaryBody } from "@/lib/summarization";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
|
||||
interface CompactionSummaryProps {
|
||||
/** Raw summary message content (wrapper + body). */
|
||||
@@ -19,8 +20,11 @@ interface CompactionSummaryProps {
|
||||
budgetHardTokens?: number | null;
|
||||
}
|
||||
|
||||
function formatTokens(value: number): string {
|
||||
return value.toLocaleString("en-US");
|
||||
function formatTokens(
|
||||
format: ReturnType<typeof useFormatter>,
|
||||
value: number
|
||||
): string {
|
||||
return format.number(value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +41,8 @@ export const CompactionSummary = React.memo<CompactionSummaryProps>(
|
||||
estimatedTokensAfter,
|
||||
budgetHardTokens,
|
||||
}) => {
|
||||
const t = useTranslations("chat");
|
||||
const format = useFormatter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const body = extractSummaryBody(content);
|
||||
const hasTokenStats =
|
||||
@@ -57,17 +63,19 @@ export const CompactionSummary = React.memo<CompactionSummaryProps>(
|
||||
className="size-3.5 text-[var(--brand)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Conversation compacted
|
||||
{t("compaction.title")}
|
||||
{typeof summarizedCount === "number" && summarizedCount > 0 && (
|
||||
<span className="text-muted-foreground/70">
|
||||
· {summarizedCount} earlier{" "}
|
||||
{summarizedCount === 1 ? "message" : "messages"} summarized
|
||||
· {t("compaction.summarized", { count: summarizedCount })}
|
||||
</span>
|
||||
)}
|
||||
{hasTokenStats && (
|
||||
<span className="text-muted-foreground/70">
|
||||
· ≈{formatTokens(estimatedTokensBefore)} → ≈
|
||||
{formatTokens(estimatedTokensAfter)} tokens
|
||||
·{" "}
|
||||
{t("compaction.tokenStats", {
|
||||
before: formatTokens(format, estimatedTokensBefore),
|
||||
after: formatTokens(format, estimatedTokensAfter),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight
|
||||
@@ -85,13 +93,16 @@ export const CompactionSummary = React.memo<CompactionSummaryProps>(
|
||||
<MarkdownContent content={body} />
|
||||
{hasTokenStats && (
|
||||
<p className="mt-3 border-t border-border/60 pt-2 text-[11px] text-muted-foreground/80">
|
||||
Prompt size (estimate): ≈{formatTokens(estimatedTokensBefore)}{" "}
|
||||
tokens before compaction, ≈{formatTokens(estimatedTokensAfter)}{" "}
|
||||
after
|
||||
{typeof budgetHardTokens === "number" &&
|
||||
` · hard message budget ≈${formatTokens(budgetHardTokens)} tokens`}
|
||||
. Exact usage appears in token consumption once the next model
|
||||
call completes.
|
||||
{typeof budgetHardTokens === "number"
|
||||
? t("compaction.detailsWithBudget", {
|
||||
before: formatTokens(format, estimatedTokensBefore),
|
||||
after: formatTokens(format, estimatedTokensAfter),
|
||||
budget: formatTokens(format, budgetHardTokens),
|
||||
})
|
||||
: t("compaction.detailsWithoutBudget", {
|
||||
before: formatTokens(format, estimatedTokensBefore),
|
||||
after: formatTokens(format, estimatedTokensAfter),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ContextUsageEvent,
|
||||
} from "@/lib/contextUsage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Live context-occupancy pill.
|
||||
@@ -22,6 +23,7 @@ export const ContextUsageIndicator = React.memo<{
|
||||
usage: ContextUsageEvent | null;
|
||||
measuredTokens: number | null;
|
||||
}>(function ContextUsageIndicator({ usage, measuredTokens }) {
|
||||
const t = useTranslations("chat");
|
||||
if (!usage) return null;
|
||||
const estimated = usage.usedTokens + usage.reservedTokens;
|
||||
const occupied = measuredTokens ?? estimated;
|
||||
@@ -29,16 +31,20 @@ export const ContextUsageIndicator = React.memo<{
|
||||
|
||||
const percent = Math.max(0, Math.round((occupied / limit) * 100));
|
||||
const nearlyFull = occupied >= limit * 0.85;
|
||||
const label = `ctx ${percent}% · ${formatCompactTokens(
|
||||
occupied
|
||||
)}/${formatCompactTokens(limit)}`;
|
||||
const label = t("contextUsage.label", {
|
||||
percent,
|
||||
used: formatCompactTokens(occupied),
|
||||
limit: formatCompactTokens(limit),
|
||||
});
|
||||
const tooltipParams = {
|
||||
used: occupied.toLocaleString(),
|
||||
limit: limit.toLocaleString(),
|
||||
soft: usage.softTokens.toLocaleString(),
|
||||
};
|
||||
const tooltip =
|
||||
(measuredTokens !== null
|
||||
? `Last model call used ${occupied.toLocaleString()} of ` +
|
||||
`${limit.toLocaleString()} tokens (measured)`
|
||||
: `Context used (estimate): ${occupied.toLocaleString()} of ` +
|
||||
`${limit.toLocaleString()} tokens`) +
|
||||
` · auto-compact messages at ≈${usage.softTokens.toLocaleString()}.`;
|
||||
measuredTokens !== null
|
||||
? t("contextUsage.tooltipMeasured", tooltipParams)
|
||||
: t("contextUsage.tooltipEstimate", tooltipParams);
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bot, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||
import type { SubAgent } from "@/app/types/types";
|
||||
@@ -13,6 +14,7 @@ interface SubAgentIndicatorProps {
|
||||
|
||||
export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
|
||||
({ subAgent, onClick, isExpanded = true }) => {
|
||||
const t = useTranslations("chat");
|
||||
const running = subAgent.status === "pending";
|
||||
return (
|
||||
<div className="w-fit max-w-[70vw] overflow-hidden rounded-lg bg-card">
|
||||
@@ -39,7 +41,7 @@ export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
|
||||
{subAgent.subAgentName}
|
||||
</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
subagent
|
||||
{t("subAgent.badge")}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTime, formatFullTime } from "@/lib/time";
|
||||
import type { ThreadItem } from "@/app/hooks/useThreads";
|
||||
import {
|
||||
useThreads,
|
||||
@@ -52,16 +51,18 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
|
||||
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
|
||||
|
||||
const GROUP_LABELS = {
|
||||
interrupted: "Requiring Attention",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
week: "This Week",
|
||||
older: "Older",
|
||||
} as const;
|
||||
const GROUP_KEYS = [
|
||||
"interrupted",
|
||||
"today",
|
||||
"yesterday",
|
||||
"week",
|
||||
"older",
|
||||
] as const;
|
||||
type GroupKey = (typeof GROUP_KEYS)[number];
|
||||
|
||||
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
|
||||
idle: "bg-transparent ring-1 ring-inset ring-green-500",
|
||||
@@ -102,9 +103,10 @@ function StatusFilterItem({
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
const t = useTranslations("threadList");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-red-600">Failed to load research</p>
|
||||
<p className="text-sm text-red-600">{t("loadFailed")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -124,10 +126,11 @@ function LoadingState() {
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
const t = useTranslations("threadList");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-muted-foreground">No research yet</p>
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -147,6 +150,9 @@ export function ThreadList({
|
||||
onMutateReady,
|
||||
onInterruptCountChange,
|
||||
}: ThreadListProps) {
|
||||
const t = useTranslations("threadList");
|
||||
const tCommon = useTranslations("common");
|
||||
const format = useFormatter();
|
||||
const [currentThreadId, setThreadId] = useQueryState("threadId");
|
||||
const [view, setView] = useQueryState("view");
|
||||
// Badge the Memory nav when long-term memory changed since last viewed.
|
||||
@@ -245,7 +251,7 @@ export function ThreadList({
|
||||
|
||||
// Group threads by time and status
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
|
||||
const groups: Record<GroupKey, ThreadItem[]> = {
|
||||
interrupted: [],
|
||||
today: [],
|
||||
yesterday: [],
|
||||
@@ -279,6 +285,40 @@ export function ThreadList({
|
||||
return groups;
|
||||
}, [filtered, now, needsAttention]);
|
||||
|
||||
const groupLabels: Record<GroupKey, string> = {
|
||||
interrupted: t("groups.requiringAttention"),
|
||||
today: t("groups.today"),
|
||||
yesterday: t("groups.yesterday"),
|
||||
week: t("groups.thisWeek"),
|
||||
older: t("groups.older"),
|
||||
};
|
||||
|
||||
// Locale-aware replacements for lib/time.ts (which hardcodes "Yesterday" and
|
||||
// uses the browser locale; we want the app locale).
|
||||
const formatThreadTime = (date: Date): string => {
|
||||
if (!Number.isFinite(date.getTime())) return "—";
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
if (days <= 0) {
|
||||
return format.dateTime(date, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
}
|
||||
if (days === 1) return t("groups.yesterday");
|
||||
if (days < 7) return format.dateTime(date, { weekday: "long" });
|
||||
return format.dateTime(date, { month: "2-digit", day: "2-digit" });
|
||||
};
|
||||
const formatThreadFullTime = (date: Date): string => {
|
||||
if (!Number.isFinite(date.getTime())) return t("unknownTime");
|
||||
return format.dateTime(date, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
};
|
||||
|
||||
const interruptedCount = useMemo(() => {
|
||||
// Same predicate as the `grouped` bucket so the badge matches the visible
|
||||
// group exactly.
|
||||
@@ -338,7 +378,7 @@ export function ThreadList({
|
||||
setRenameTarget(null);
|
||||
mutateFn();
|
||||
} catch {
|
||||
errorToast("threads.rename", "Couldn't rename — try again.");
|
||||
errorToast("threads.rename", t("renameFailed"));
|
||||
} finally {
|
||||
actionBusyRef.current = false;
|
||||
setActionBusy(false);
|
||||
@@ -366,7 +406,7 @@ export function ThreadList({
|
||||
setDeleteTarget(null);
|
||||
mutateFn();
|
||||
} catch {
|
||||
errorToast("threads.delete", "Couldn't delete — try again.");
|
||||
errorToast("threads.delete", t("deleteFailed"));
|
||||
} finally {
|
||||
actionBusyRef.current = false;
|
||||
setActionBusy(false);
|
||||
@@ -387,9 +427,7 @@ export function ThreadList({
|
||||
} catch {
|
||||
errorToast(
|
||||
"threads.pin",
|
||||
thread.pinned
|
||||
? "Couldn't unpin — try again."
|
||||
: "Couldn't pin — try again."
|
||||
thread.pinned ? t("unpinFailed") : t("pinFailed")
|
||||
);
|
||||
} finally {
|
||||
pinBusyIdsRef.current.delete(thread.id);
|
||||
@@ -412,7 +450,7 @@ export function ThreadList({
|
||||
try {
|
||||
await exportThread(thread.id, thread.title);
|
||||
} catch {
|
||||
errorToast("threads.export", "Couldn't export — try again.");
|
||||
errorToast("threads.export", t("exportFailed"));
|
||||
} finally {
|
||||
exportBusyIdsRef.current.delete(thread.id);
|
||||
setExportBusyIds((current) => {
|
||||
@@ -464,9 +502,9 @@ export function ThreadList({
|
||||
<span className="ml-2 flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
<time
|
||||
dateTime={thread.updatedAt.toISOString()}
|
||||
title={formatFullTime(thread.updatedAt)}
|
||||
title={formatThreadFullTime(thread.updatedAt)}
|
||||
>
|
||||
{formatTime(thread.updatedAt, now)}
|
||||
{formatThreadTime(thread.updatedAt)}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
@@ -479,10 +517,10 @@ export function ThreadList({
|
||||
type="button"
|
||||
aria-label={
|
||||
thread.pinned
|
||||
? `Unpin "${thread.title}"`
|
||||
: `Pin "${thread.title}"`
|
||||
? t("unpinAria", { title: thread.title })
|
||||
: t("pinAria", { title: thread.title })
|
||||
}
|
||||
title={thread.pinned ? "Unpin" : "Pin"}
|
||||
title={thread.pinned ? t("unpin") : t("pin")}
|
||||
onClick={() => togglePin(thread)}
|
||||
disabled={pinBusy}
|
||||
className={cn(
|
||||
@@ -506,8 +544,8 @@ export function ThreadList({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Rename "${thread.title}"`}
|
||||
title="Rename"
|
||||
aria-label={t("renameAria", { title: thread.title })}
|
||||
title={t("rename")}
|
||||
onClick={() => {
|
||||
setRenameTarget(thread);
|
||||
setRenameValue(thread.title);
|
||||
@@ -521,8 +559,8 @@ export function ThreadList({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Export "${thread.title}" as JSON`}
|
||||
title="Export JSON"
|
||||
aria-label={t("exportAria", { title: thread.title })}
|
||||
title={t("exportJson")}
|
||||
onClick={() => runExport(thread)}
|
||||
disabled={exportBusy}
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@@ -541,8 +579,8 @@ export function ThreadList({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Delete "${thread.title}"`}
|
||||
title="Delete"
|
||||
aria-label={t("deleteAria", { title: thread.title })}
|
||||
title={tCommon("delete")}
|
||||
onClick={() => setDeleteTarget(thread)}
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
@@ -576,7 +614,7 @@ export function ThreadList({
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
New Chat
|
||||
{t("newChat")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -599,7 +637,7 @@ export function ThreadList({
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Research Skills
|
||||
{t("researchSkills")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -623,14 +661,12 @@ export function ThreadList({
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
EvoMemory
|
||||
{t("evoMemory")}
|
||||
{view !== "memory" && memoryUnseen > 0 && (
|
||||
<span
|
||||
className="ml-auto inline-flex min-w-4 items-center justify-center rounded-full bg-[var(--brand-solid)] px-1 text-[10px] font-bold leading-none text-[var(--brand-foreground)]"
|
||||
title={`${memoryUnseen} memory file${
|
||||
memoryUnseen === 1 ? "" : "s"
|
||||
} updated since you last looked`}
|
||||
aria-label={`${memoryUnseen} memory updates`}
|
||||
title={t("memoryUpdatesTitle", { count: memoryUnseen })}
|
||||
aria-label={t("memoryUpdatesAria", { count: memoryUnseen })}
|
||||
>
|
||||
{memoryUnseen}
|
||||
</span>
|
||||
@@ -657,7 +693,7 @@ export function ThreadList({
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Scheduled
|
||||
{t("scheduled")}
|
||||
</button>
|
||||
<div className="flex-shrink-0 border-b border-border p-2.5">
|
||||
<div className="relative">
|
||||
@@ -670,8 +706,8 @@ export function ThreadList({
|
||||
name="research-search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search research…"
|
||||
aria-label="Search research"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
aria-label={t("searchAria")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-border bg-background py-1.5 pl-8 pr-8 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@@ -679,7 +715,7 @@ export function ThreadList({
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear research search"
|
||||
aria-label={t("clearSearch")}
|
||||
onClick={() => setSearch("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
@@ -692,7 +728,9 @@ export function ThreadList({
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
<h2 className="text-base font-semibold tracking-tight">Recents</h2>
|
||||
<h2 className="text-base font-semibold tracking-tight">
|
||||
{t("recents")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
@@ -702,37 +740,37 @@ export function ThreadList({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="all">{t("filterAll")}</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Active</SelectLabel>
|
||||
<SelectLabel>{t("filterActive")}</SelectLabel>
|
||||
<SelectItem value="idle">
|
||||
<StatusFilterItem
|
||||
status="idle"
|
||||
label="Idle"
|
||||
label={t("filterIdle")}
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="busy">
|
||||
<StatusFilterItem
|
||||
status="busy"
|
||||
label="Busy"
|
||||
label={t("filterBusy")}
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Attention</SelectLabel>
|
||||
<SelectLabel>{t("filterAttention")}</SelectLabel>
|
||||
<SelectItem value="interrupted">
|
||||
<StatusFilterItem
|
||||
status="interrupted"
|
||||
label="Interrupted"
|
||||
label={t("filterInterrupted")}
|
||||
badge={interruptedCount}
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="error">
|
||||
<StatusFilterItem
|
||||
status="error"
|
||||
label="Error"
|
||||
label={t("filterError")}
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
@@ -741,7 +779,7 @@ export function ThreadList({
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={view ? "Close navigation" : "Close research"}
|
||||
aria-label={view ? t("closeNavigation") : t("closeResearch")}
|
||||
onClick={onClose}
|
||||
className="rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
@@ -771,7 +809,7 @@ export function ThreadList({
|
||||
filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No research matches your search.
|
||||
{t("noMatch")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -782,7 +820,7 @@ export function ThreadList({
|
||||
{pinned.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="m-0 px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Research
|
||||
{t("pinnedSection")}
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{pinned.map((thread) => renderThreadCard(thread))}
|
||||
@@ -790,9 +828,7 @@ export function ThreadList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(
|
||||
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
|
||||
).map((group) => {
|
||||
{GROUP_KEYS.map((group) => {
|
||||
const groupThreads = grouped[group];
|
||||
if (groupThreads.length === 0) return null;
|
||||
|
||||
@@ -802,7 +838,7 @@ export function ThreadList({
|
||||
className="mb-3"
|
||||
>
|
||||
<h4 className="m-0 px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{GROUP_LABELS[group]}
|
||||
{groupLabels[group]}
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{groupThreads.map((thread) => renderThreadCard(thread))}
|
||||
@@ -822,10 +858,10 @@ export function ThreadList({
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
{tCommon("loading")}
|
||||
</>
|
||||
) : (
|
||||
"Load More"
|
||||
t("loadMore")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -844,9 +880,9 @@ export function ThreadList({
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename research</DialogTitle>
|
||||
<DialogTitle>{t("renameDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Give this conversation a custom title.
|
||||
{t("renameDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
@@ -859,7 +895,7 @@ export function ThreadList({
|
||||
submitRename();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter a title…"
|
||||
placeholder={t("renameDialog.placeholder")}
|
||||
maxLength={100}
|
||||
disabled={actionBusy}
|
||||
/>
|
||||
@@ -869,13 +905,13 @@ export function ThreadList({
|
||||
onClick={() => setRenameTarget(null)}
|
||||
disabled={actionBusy}
|
||||
>
|
||||
Cancel
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submitRename}
|
||||
disabled={actionBusy || !renameValue.trim()}
|
||||
>
|
||||
{actionBusy ? "Saving…" : "Save"}
|
||||
{actionBusy ? t("renameDialog.saving") : tCommon("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -902,10 +938,11 @@ export function ThreadList({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete this research?</DialogTitle>
|
||||
<DialogTitle>{t("deleteDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
“{deleteTarget?.title}” will be permanently deleted. This can’t be
|
||||
undone.
|
||||
{t("deleteDialog.description", {
|
||||
title: deleteTarget?.title ?? "",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -914,14 +951,14 @@ export function ThreadList({
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={actionBusy}
|
||||
>
|
||||
Cancel
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmDelete}
|
||||
disabled={actionBusy}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{actionBusy ? "Deleting…" : "Delete"}
|
||||
{actionBusy ? t("deleteDialog.deleting") : tCommon("delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AlertCircle, Check, X, Pencil, ShieldCheck } from "lucide-react";
|
||||
import type { ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReviewMode } from "@/lib/reviewMode";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ToolApprovalInterruptProps {
|
||||
actionRequest: ActionRequest;
|
||||
@@ -48,6 +49,8 @@ export function ToolApprovalInterrupt({
|
||||
isLoading,
|
||||
onSubmitted,
|
||||
}: ToolApprovalInterruptProps) {
|
||||
const t = useTranslations("chat");
|
||||
const tCommon = useTranslations("common");
|
||||
const [rejectionMessage, setRejectionMessage] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>({});
|
||||
@@ -167,7 +170,7 @@ export function ToolApprovalInterrupt({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
Approval Required
|
||||
{t("toolApproval.title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -182,7 +185,7 @@ export function ToolApprovalInterrupt({
|
||||
<div className="mb-4 rounded-sm border border-border bg-background p-3">
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Tool
|
||||
{t("toolApproval.tool")}
|
||||
</span>
|
||||
<p className="mt-1 font-mono text-sm font-medium text-foreground">
|
||||
{actionRequest.name}
|
||||
@@ -192,7 +195,7 @@ export function ToolApprovalInterrupt({
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Edit Arguments
|
||||
{t("toolApproval.editArguments")}
|
||||
</span>
|
||||
<div className="mt-2 space-y-3">
|
||||
{Object.entries(actionArgs).map(([key, value]) => (
|
||||
@@ -224,7 +227,7 @@ export function ToolApprovalInterrupt({
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Arguments
|
||||
{t("toolApproval.arguments")}
|
||||
</span>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs text-foreground">
|
||||
{formatValue(actionArgs)}
|
||||
@@ -237,12 +240,12 @@ export function ToolApprovalInterrupt({
|
||||
{showRejectionInput && !isEditing && (
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-medium text-foreground">
|
||||
Rejection Message (optional)
|
||||
{t("toolApproval.rejectionMessage")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={rejectionMessage}
|
||||
onChange={(e) => setRejectionMessage(e.target.value)}
|
||||
placeholder="Explain why you're rejecting this action…"
|
||||
placeholder={t("toolApproval.rejectionPlaceholder")}
|
||||
className="text-sm"
|
||||
rows={2}
|
||||
disabled={isLoading}
|
||||
@@ -261,7 +264,7 @@ export function ToolApprovalInterrupt({
|
||||
onClick={cancelEditing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -274,7 +277,9 @@ export function ToolApprovalInterrupt({
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{isLoading ? "Saving…" : "Save & Approve"}
|
||||
{isLoading
|
||||
? t("toolApproval.saving")
|
||||
: t("toolApproval.saveApprove")}
|
||||
</Button>
|
||||
</>
|
||||
) : showRejectionInput ? (
|
||||
@@ -289,7 +294,7 @@ export function ToolApprovalInterrupt({
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -298,7 +303,9 @@ export function ToolApprovalInterrupt({
|
||||
onClick={handleRejectConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Rejecting…" : "Confirm Reject"}
|
||||
{isLoading
|
||||
? t("toolApproval.rejecting")
|
||||
: t("toolApproval.confirmReject")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -316,7 +323,7 @@ export function ToolApprovalInterrupt({
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Reject
|
||||
{t("toolApproval.reject")}
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("edit") && (
|
||||
@@ -331,7 +338,7 @@ export function ToolApprovalInterrupt({
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Edit
|
||||
{t("toolApproval.edit")}
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("approve") && (
|
||||
@@ -350,7 +357,9 @@ export function ToolApprovalInterrupt({
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{isLoading ? "Approving…" : "Approve once"}
|
||||
{isLoading
|
||||
? t("toolApproval.approving")
|
||||
: t("toolApproval.approveOnce")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -364,7 +373,7 @@ export function ToolApprovalInterrupt({
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Auto-approve this chat
|
||||
{t("toolApproval.approveAuto")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,9 @@ import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
|
||||
import { formatToolLabel, toolArgumentPreview } from "@/lib/toolLabel";
|
||||
import { formatToolLabelWith, toolArgumentPreview } from "@/lib/toolLabel";
|
||||
import { useToolLabels } from "@/app/hooks/useToolLabels";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ReviewMode } from "@/lib/reviewMode";
|
||||
|
||||
// One-line preview of a tool-call argument value, shown next to the key in
|
||||
@@ -75,6 +77,8 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
autoApprove,
|
||||
compact = false,
|
||||
}) => {
|
||||
const t = useTranslations("chat");
|
||||
const toolLabels = useToolLabels();
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
() => !!uiComponent || (!!actionRequest && !autoApprove)
|
||||
);
|
||||
@@ -126,7 +130,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
// args view, so Object.keys/entries never run on a string.
|
||||
const rawArgs: unknown = toolCall.args;
|
||||
return {
|
||||
name: formatToolLabel(toolCall.name, rawArgs),
|
||||
name: formatToolLabelWith(toolLabels, toolCall.name, rawArgs),
|
||||
argumentPreview: toolArgumentPreview(toolCall.name, rawArgs),
|
||||
args:
|
||||
rawArgs && typeof rawArgs === "object"
|
||||
@@ -135,7 +139,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
result: toolCall.result,
|
||||
status: toolCall.status || "completed",
|
||||
};
|
||||
}, [toolCall]);
|
||||
}, [toolCall, toolLabels]);
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
switch (status) {
|
||||
@@ -256,7 +260,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
stream={stream}
|
||||
message={uiComponent}
|
||||
namespace={graphId}
|
||||
meta={{ status, args, result: result ?? "No Result Yet" }}
|
||||
meta={{ status, args, result: result ?? t("toolCall.noResultYet") }}
|
||||
/>
|
||||
</div>
|
||||
) : showApproval ? (
|
||||
@@ -281,7 +285,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
{Object.keys(args).length > 0 && (
|
||||
<div className={cn("mt-4", compact && "mt-2")}>
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Arguments
|
||||
{t("toolCall.arguments")}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(args).map(([key, value]) => {
|
||||
@@ -354,7 +358,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
{hasResult && (
|
||||
<div className={cn("mt-4", compact && "mt-2")}>
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Result
|
||||
{t("toolCall.result")}
|
||||
</h4>
|
||||
<pre
|
||||
className={cn(
|
||||
|
||||
+23
-20
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/lib/contextUsage";
|
||||
import { findActiveTurnId } from "@/lib/usageTurn";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ModelRef, ThreadModelSelection } from "@/lib/modelRegistry";
|
||||
import { setThreadModelSelection, setThreadRunning } from "@/app/hooks/useThreads";
|
||||
import {
|
||||
@@ -228,7 +229,7 @@ function latestTaskInterrupt(
|
||||
// `message`, JSON-of-`.error`, the raw string, finally a generic fallback.
|
||||
// Capped at 300 chars so a giant stack trace doesn't blow up the toast; the
|
||||
// full text is still available in the thread JSON via the export affordance.
|
||||
function formatStreamError(error: unknown): string {
|
||||
function formatStreamError(error: unknown): string | null {
|
||||
const cap = (s: string) => (s.length > 300 ? s.slice(0, 297) + "..." : s);
|
||||
if (typeof error === "string" && error.trim()) return cap(error.trim());
|
||||
if (error && typeof error === "object") {
|
||||
@@ -249,7 +250,8 @@ function formatStreamError(error: unknown): string {
|
||||
const combined = name && body ? `${name}: ${body}` : name ?? body ?? "";
|
||||
if (combined) return cap(combined);
|
||||
}
|
||||
return "Run failed.";
|
||||
// Caller supplies the localized generic fallback.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stable empty fallbacks. Returning `?? []` / `?? {}` inline produces a NEW
|
||||
@@ -281,6 +283,7 @@ export function useChat({
|
||||
onHistoryRevalidate?: () => void;
|
||||
}) {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const t = useTranslations("chat");
|
||||
void activeAssistant;
|
||||
const [runConnection, setRunConnection] = useState<RunConnection | null>(
|
||||
null
|
||||
@@ -297,17 +300,20 @@ export function useChat({
|
||||
const stoppedRunIdsRef = useRef(new Set<string>());
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
const showError = useCallback((error: unknown, key: string) => {
|
||||
const message = formatStreamError(error);
|
||||
const now = Date.now();
|
||||
if (
|
||||
lastErrorToastRef.current?.key !== `${key}:${message}` ||
|
||||
now - lastErrorToastRef.current.at > 10_000
|
||||
) {
|
||||
lastErrorToastRef.current = { key: `${key}:${message}`, at: now };
|
||||
errorToast("chat", message);
|
||||
}
|
||||
}, []);
|
||||
const showError = useCallback(
|
||||
(error: unknown, key: string) => {
|
||||
const message = formatStreamError(error) ?? t("runFailed");
|
||||
const now = Date.now();
|
||||
if (
|
||||
lastErrorToastRef.current?.key !== `${key}:${message}` ||
|
||||
now - lastErrorToastRef.current.at > 10_000
|
||||
) {
|
||||
lastErrorToastRef.current = { key: `${key}:${message}`, at: now };
|
||||
errorToast("chat", message);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const isRunLoading = isSubmitting || runConnection !== null;
|
||||
const isReconnecting = runConnection?.phase === "reconnecting";
|
||||
@@ -422,10 +428,10 @@ export function useChat({
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const { thread: t } = await getConversation(threadId);
|
||||
const { thread } = await getConversation(threadId);
|
||||
if (cancelled) return;
|
||||
const stored = readSelectionFromMetadata(
|
||||
(t.metadata ?? {}) as Record<string, unknown>
|
||||
(thread.metadata ?? {}) as Record<string, unknown>
|
||||
);
|
||||
setModelSelectionState(stored.selection);
|
||||
setSelectionRevision(stored.revision);
|
||||
@@ -437,17 +443,14 @@ export function useChat({
|
||||
activeRunRef.current = null;
|
||||
setRunConnection(null);
|
||||
void setThreadId(null);
|
||||
errorToast(
|
||||
"chat.conversation",
|
||||
"Conversation is no longer available. Started a new chat."
|
||||
);
|
||||
errorToast("chat.conversation", t("conversationUnavailable"));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [setThreadId, threadId]);
|
||||
}, [setThreadId, t, threadId]);
|
||||
|
||||
// Persist + apply locally. Pre-thread (new chat with no threadId yet),
|
||||
// stashes the pick in a ref so the thread-id effect can persist it as soon
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ToolLabelStrings } from "@/lib/toolLabel";
|
||||
|
||||
/**
|
||||
* Locale-aware tool labels for `formatToolLabelWith`. Tool labels are rendered
|
||||
* inside ToolCallBox / ActionGroup headers, so they follow the chat domain.
|
||||
*/
|
||||
export function useToolLabels(): ToolLabelStrings {
|
||||
const t = useTranslations("chat.toolLabels");
|
||||
return {
|
||||
unknownTool: t("unknownTool"),
|
||||
adaptiveToolSelection: t("adaptiveToolSelection"),
|
||||
reflection: t("reflection"),
|
||||
readingMemory: t("readingMemory"),
|
||||
updatingMemory: t("updatingMemory"),
|
||||
skill: (name) => t("skill", { name }),
|
||||
execute: t("execute"),
|
||||
grep: t("grep"),
|
||||
readFile: t("readFile"),
|
||||
writeFile: t("writeFile"),
|
||||
editFile: t("editFile"),
|
||||
listFiles: t("listFiles"),
|
||||
task: t("task"),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,253 @@
|
||||
export default {
|
||||
chat: {},
|
||||
chat: {
|
||||
send: "Send",
|
||||
sendMessage: "Send message",
|
||||
stop: "Stop",
|
||||
stopGenerating: "Stop generating",
|
||||
messageInputLabel: "Message",
|
||||
placeholderDefault: "Ask EvoScientist anything…",
|
||||
placeholderQueueFollowUp: "Queue a follow-up — sends when this turn finishes…",
|
||||
placeholderReconnecting: "Reconnecting to the running task…",
|
||||
placeholderRespondToInterrupt: "Respond to the request above to continue…",
|
||||
emptyStateTitle: "Where research evolves",
|
||||
emptyStateSubtitle:
|
||||
"Your self-evolving lab partner — reads the literature, runs experiments, and remembers what matters.",
|
||||
suggestedSurvey: "Survey recent papers on a topic",
|
||||
suggestedExperiment: "Design an experiment plan",
|
||||
suggestedFiles: "Analyze workspace files",
|
||||
editMessage: "Edit message",
|
||||
thinkingTimer: "Thinking…",
|
||||
runFailed: "Run failed.",
|
||||
conversationUnavailable:
|
||||
"Conversation is no longer available. Started a new chat.",
|
||||
upload: {
|
||||
aria: "Upload files to workspace",
|
||||
title: "Upload files to workspace (max 50 MB each)",
|
||||
failed: "Failed to upload files.",
|
||||
uploaded:
|
||||
"{count, plural, one {# file uploaded to the workspace.} other {# files uploaded to the workspace.}}",
|
||||
attachedFiles: "Attached files",
|
||||
removeAttachment: "Remove {name} from this message",
|
||||
},
|
||||
reviewMode: {
|
||||
label: "Review mode",
|
||||
manual: {
|
||||
label: "Manual review",
|
||||
description: "Approve each tool action before it runs.",
|
||||
},
|
||||
auto: {
|
||||
label: "Auto-approve",
|
||||
description:
|
||||
"Approve tool actions automatically; questions can still pause.",
|
||||
},
|
||||
full: {
|
||||
label: "Full approve",
|
||||
description:
|
||||
"Approve tool actions automatically and continue without questions.",
|
||||
},
|
||||
autoEnabled: "Auto-approve enabled for this chat.",
|
||||
manualEnabled: "Manual review enabled for this chat.",
|
||||
fullEnabled: "Full approve enabled for this chat.",
|
||||
fullApprove: {
|
||||
title: "Enable Full approve?",
|
||||
description:
|
||||
"EvoScientist will approve reviewed tool actions automatically and will not pause to ask you for clarification.",
|
||||
warning:
|
||||
"When details are missing, the model will make reasonable, conservative assumptions and continue. This setting applies only to the current research.",
|
||||
confirm: "Enable Full approve",
|
||||
},
|
||||
},
|
||||
modelPicker: {
|
||||
title: "Choose model",
|
||||
description:
|
||||
"Saved for this chat; the next message freezes it into the run’s snapshot.",
|
||||
current: "Current:",
|
||||
currentDefault: "{model} · default",
|
||||
registryDefault: "registry default",
|
||||
loadingModels: "loading models…",
|
||||
unavailable: "The deployment’s selectable model list didn’t load",
|
||||
filterPlaceholder: "Filter by name or provider…",
|
||||
noMatch: "No models match “{query}”.",
|
||||
noneEnabled: "No models are enabled.",
|
||||
setSuccess: "Model set to {model}.",
|
||||
resetSuccess: "Model reset to the registry default.",
|
||||
updateFailed: "Couldn’t update model: {message}",
|
||||
updateFailedGeneric: "Couldn’t update model — try again.",
|
||||
resetFailed: "Couldn’t reset model: {message}",
|
||||
resetFailedGeneric: "Couldn’t reset model — try again.",
|
||||
resetToDefault: "Reset to default",
|
||||
changeTitle: "Click to change model for this chat",
|
||||
changeAria: "Change model for this chat",
|
||||
},
|
||||
reasoning: {
|
||||
aria: "Reasoning effort",
|
||||
title: "Reasoning effort for the primary model",
|
||||
titleDisabled: "Pick a model to override the registry-configured effort",
|
||||
auto: "auto",
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
reset: "Reasoning effort back to the registry default.",
|
||||
set: "Reasoning effort set to {effort}.",
|
||||
failed: "Couldn’t update reasoning effort: {message}",
|
||||
failedGeneric: "Couldn’t update reasoning effort — try again.",
|
||||
},
|
||||
sampling: {
|
||||
aria: "Sampling override",
|
||||
title: "Sampling override: temperature and top_p are mutually exclusive",
|
||||
titleDisabled:
|
||||
"Pick a model to override the registry-configured sampling",
|
||||
modeDefault: "Default",
|
||||
temperature: "Temperature",
|
||||
topP: "Top P",
|
||||
unset: "unset",
|
||||
exactValue: "{label} exact value",
|
||||
clickToType: "Click to type an exact {label} value",
|
||||
sliderTitleDisabled:
|
||||
"Pick a model to override the registry-configured {label}",
|
||||
sliderTitleDefault:
|
||||
"{label}: registry default {value} — drag to override or click the value to type one",
|
||||
sliderTitleOverride:
|
||||
"{label} override — click the value to type an exact one; empty + Enter resets",
|
||||
reset: "Sampling back to the registry default.",
|
||||
temperatureSet: "Temperature set to {value} (top_p omitted).",
|
||||
topPSet: "Top P set to {value} (temperature omitted).",
|
||||
failed: "Couldn’t update sampling: {message}",
|
||||
failedGeneric: "Couldn’t update sampling — try again.",
|
||||
},
|
||||
usage: {
|
||||
tokensLoading: "Tokens loading",
|
||||
tokensUnavailable: "Tokens unavailable",
|
||||
tokensLabel: "{count} tokens",
|
||||
viewAria: "View token usage: {label}",
|
||||
},
|
||||
agents: {
|
||||
runningTitle:
|
||||
"{count, plural, one {# background agent running — click to view} other {# background agents running — click to view}}",
|
||||
runningAria:
|
||||
"{count, plural, one {# background agent running — view} other {# background agents running — view}}",
|
||||
runningLabel: "{count, plural, one {# agent} other {# agents}}",
|
||||
autoReported: "Auto-reported {agent} to the main chat.",
|
||||
},
|
||||
queue: {
|
||||
label: "Queued messages",
|
||||
filesAttached: "{count, plural, one {# file} other {# files}}",
|
||||
reorderAria: "Reorder queued message {index} of {total}",
|
||||
reorderTitle: "Drag to reorder, or use the arrow keys",
|
||||
steerAria: "Steer with this message next",
|
||||
steerTitle: "Submit next without interrupting the current turn",
|
||||
steer: "Steer",
|
||||
steerToast: "This message will run next without interrupting the current turn.",
|
||||
removeAria: "Remove queued message",
|
||||
removeTitle: "Remove from queue",
|
||||
moreAria: "More queued message actions",
|
||||
moreTitle: "More actions",
|
||||
closeQueue: "Close queue",
|
||||
},
|
||||
tasks: {
|
||||
allCompleted: "All tasks completed",
|
||||
progress: "Task {current} of {total}",
|
||||
filesState: "Files (State)",
|
||||
tasks: "Tasks",
|
||||
pending: "Pending",
|
||||
inProgress: "In Progress",
|
||||
completed: "Completed",
|
||||
},
|
||||
workspace: {
|
||||
files: "Files",
|
||||
openFiles: "Open conversation files",
|
||||
closeFiles: "Close conversation files",
|
||||
},
|
||||
message: {
|
||||
copy: "Copy message",
|
||||
copied: "Copied",
|
||||
copyFailed: "Couldn’t copy to clipboard.",
|
||||
thinking: "Thinking",
|
||||
backgroundAgentReported: "Background agent reported back",
|
||||
},
|
||||
subAgent: {
|
||||
badge: "subagent",
|
||||
input: "Input",
|
||||
steps: "Steps",
|
||||
output: "Output",
|
||||
},
|
||||
toolCall: {
|
||||
arguments: "Arguments",
|
||||
result: "Result",
|
||||
noResultYet: "No Result Yet",
|
||||
},
|
||||
toolApproval: {
|
||||
title: "Approval Required",
|
||||
tool: "Tool",
|
||||
editArguments: "Edit Arguments",
|
||||
arguments: "Arguments",
|
||||
rejectionMessage: "Rejection Message (optional)",
|
||||
rejectionPlaceholder: "Explain why you’re rejecting this action…",
|
||||
saveApprove: "Save & Approve",
|
||||
saving: "Saving…",
|
||||
confirmReject: "Confirm Reject",
|
||||
rejecting: "Rejecting…",
|
||||
reject: "Reject",
|
||||
edit: "Edit",
|
||||
approveOnce: "Approve once",
|
||||
approving: "Approving…",
|
||||
approveAuto: "Auto-approve this chat",
|
||||
},
|
||||
askUser: {
|
||||
title: "EvoScientist Needs Your Input",
|
||||
other: "Other…",
|
||||
answerPlaceholder: "Type your answer…",
|
||||
answerPlaceholderOptional: "Type your answer… (optional)",
|
||||
submit: "Submit",
|
||||
submitting: "Submitting…",
|
||||
},
|
||||
compaction: {
|
||||
title: "Conversation compacted",
|
||||
summarized:
|
||||
"{count, plural, one {# earlier message summarized} other {# earlier messages summarized}}",
|
||||
tokenStats: "≈{before} → ≈{after} tokens",
|
||||
detailsWithoutBudget:
|
||||
"Prompt size (estimate): ≈{before} tokens before compaction, ≈{after} after. Exact usage appears in token consumption once the next model call completes.",
|
||||
detailsWithBudget:
|
||||
"Prompt size (estimate): ≈{before} tokens before compaction, ≈{after} after · hard message budget ≈{budget} tokens. Exact usage appears in token consumption once the next model call completes.",
|
||||
},
|
||||
actionGroup: {
|
||||
ariaLabel: "Assistant work process",
|
||||
working: "Working",
|
||||
approvalRequired: "Approval required",
|
||||
failed:
|
||||
"{failed} of {total, plural, one {# action} other {# actions}} failed",
|
||||
workingWithCount:
|
||||
"Working · {total, plural, one {# action} other {# actions}}",
|
||||
completed:
|
||||
"{total, plural, one {# action} other {# actions}} completed",
|
||||
collapse: "Collapse",
|
||||
collapseCount:
|
||||
"Collapse {total, plural, one {# action} other {# actions}}",
|
||||
collapseGroup: "Collapse {summary}",
|
||||
expandGroup: "Expand {summary}",
|
||||
},
|
||||
contextUsage: {
|
||||
label: "ctx {percent}% · {used}/{limit}",
|
||||
tooltipMeasured:
|
||||
"Last model call used {used} of {limit} tokens (measured) · auto-compact messages at ≈{soft}.",
|
||||
tooltipEstimate:
|
||||
"Context used (estimate): {used} of {limit} tokens · auto-compact messages at ≈{soft}.",
|
||||
},
|
||||
toolLabels: {
|
||||
unknownTool: "Unknown Tool",
|
||||
adaptiveToolSelection: "Adaptive tool selection",
|
||||
reflection: "Reflection",
|
||||
readingMemory: "Reading memory",
|
||||
updatingMemory: "Updating memory",
|
||||
skill: "Skill: {name}",
|
||||
execute: "Running command",
|
||||
grep: "Searching files",
|
||||
readFile: "Reading file",
|
||||
writeFile: "Writing file",
|
||||
editFile: "Editing file",
|
||||
listFiles: "Listing files",
|
||||
task: "Running subagent",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
export default {
|
||||
threadList: {},
|
||||
threadList: {
|
||||
newChat: "New Chat",
|
||||
researchSkills: "Research Skills",
|
||||
evoMemory: "EvoMemory",
|
||||
memoryUpdatesTitle:
|
||||
"{count, plural, one {# memory file updated since you last looked} other {# memory files updated since you last looked}}",
|
||||
memoryUpdatesAria:
|
||||
"{count, plural, one {# memory update} other {# memory updates}}",
|
||||
scheduled: "Scheduled",
|
||||
searchPlaceholder: "Search research…",
|
||||
searchAria: "Search research",
|
||||
clearSearch: "Clear research search",
|
||||
recents: "Recents",
|
||||
filterAll: "All",
|
||||
filterActive: "Active",
|
||||
filterIdle: "Idle",
|
||||
filterBusy: "Busy",
|
||||
filterAttention: "Attention",
|
||||
filterInterrupted: "Interrupted",
|
||||
filterError: "Error",
|
||||
closeNavigation: "Close navigation",
|
||||
closeResearch: "Close research",
|
||||
loadFailed: "Failed to load research",
|
||||
empty: "No research yet",
|
||||
noMatch: "No research matches your search.",
|
||||
pinnedSection: "Research",
|
||||
groups: {
|
||||
requiringAttention: "Requiring Attention",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
thisWeek: "This Week",
|
||||
older: "Older",
|
||||
},
|
||||
loadMore: "Load More",
|
||||
unknownTime: "Unknown time",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
pinAria: "Pin “{title}”",
|
||||
unpinAria: "Unpin “{title}”",
|
||||
rename: "Rename",
|
||||
renameAria: "Rename “{title}”",
|
||||
exportJson: "Export JSON",
|
||||
exportAria: "Export “{title}” as JSON",
|
||||
deleteAria: "Delete “{title}”",
|
||||
renameDialog: {
|
||||
title: "Rename research",
|
||||
description: "Give this conversation a custom title.",
|
||||
placeholder: "Enter a title…",
|
||||
saving: "Saving…",
|
||||
},
|
||||
deleteDialog: {
|
||||
title: "Delete this research?",
|
||||
description:
|
||||
"“{title}” will be permanently deleted. This can’t be undone.",
|
||||
deleting: "Deleting…",
|
||||
},
|
||||
renameFailed: "Couldn’t rename — try again.",
|
||||
deleteFailed: "Couldn’t delete — try again.",
|
||||
pinFailed: "Couldn’t pin — try again.",
|
||||
unpinFailed: "Couldn’t unpin — try again.",
|
||||
exportFailed: "Couldn’t export — try again.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,240 @@
|
||||
export default {
|
||||
chat: {},
|
||||
chat: {
|
||||
send: "发送",
|
||||
sendMessage: "发送消息",
|
||||
stop: "停止",
|
||||
stopGenerating: "停止生成",
|
||||
messageInputLabel: "消息",
|
||||
placeholderDefault: "向 EvoScientist 提问…",
|
||||
placeholderQueueFollowUp: "排队一条追问 —— 本轮结束后自动发送…",
|
||||
placeholderReconnecting: "正在重新连接运行中的任务…",
|
||||
placeholderRespondToInterrupt: "请先回应上方的请求以继续…",
|
||||
emptyStateTitle: "让研究持续进化",
|
||||
emptyStateSubtitle:
|
||||
"你的自我进化实验室伙伴 —— 阅读文献、运行实验,并记住关键内容。",
|
||||
suggestedSurvey: "调研某主题的最新论文",
|
||||
suggestedExperiment: "设计实验方案",
|
||||
suggestedFiles: "分析工作区文件",
|
||||
editMessage: "编辑消息",
|
||||
thinkingTimer: "思考中…",
|
||||
runFailed: "运行失败。",
|
||||
conversationUnavailable: "对话已不可用,已开启新对话。",
|
||||
upload: {
|
||||
aria: "上传文件到工作区",
|
||||
title: "上传文件到工作区(单个文件最大 50 MB)",
|
||||
failed: "文件上传失败。",
|
||||
uploaded: "{count, plural, other {已将 # 个文件上传到工作区。}}",
|
||||
attachedFiles: "已附加的文件",
|
||||
removeAttachment: "将 {name} 从这条消息中移除",
|
||||
},
|
||||
reviewMode: {
|
||||
label: "审核模式",
|
||||
manual: {
|
||||
label: "手动审核",
|
||||
description: "每个工具操作运行前都需要你批准。",
|
||||
},
|
||||
auto: {
|
||||
label: "自动批准",
|
||||
description: "自动批准工具操作;提问仍会暂停。",
|
||||
},
|
||||
full: {
|
||||
label: "完全批准",
|
||||
description: "自动批准工具操作,且不再因提问而暂停。",
|
||||
},
|
||||
autoEnabled: "已为此对话启用自动批准。",
|
||||
manualEnabled: "已为此对话启用手动审核。",
|
||||
fullEnabled: "已为此对话启用完全批准。",
|
||||
fullApprove: {
|
||||
title: "启用完全批准?",
|
||||
description:
|
||||
"EvoScientist 将自动批准需要审核的工具操作,并且不会暂停来向你求证。",
|
||||
warning:
|
||||
"当细节缺失时,模型将做出合理、保守的假设并继续执行。此设置仅应用于当前研究。",
|
||||
confirm: "启用完全批准",
|
||||
},
|
||||
},
|
||||
modelPicker: {
|
||||
title: "选择模型",
|
||||
description: "为此对话保存;下一条消息会将其冻结到运行的快照中。",
|
||||
current: "当前:",
|
||||
currentDefault: "{model} · 默认",
|
||||
registryDefault: "注册表默认",
|
||||
loadingModels: "正在加载模型…",
|
||||
unavailable: "此部署的可用模型列表未能加载",
|
||||
filterPlaceholder: "按名称或服务商筛选…",
|
||||
noMatch: "没有匹配“{query}”的模型。",
|
||||
noneEnabled: "没有已启用的模型。",
|
||||
setSuccess: "模型已设置为 {model}。",
|
||||
resetSuccess: "模型已重置为注册表默认。",
|
||||
updateFailed: "无法更新模型:{message}",
|
||||
updateFailedGeneric: "无法更新模型 —— 请重试。",
|
||||
resetFailed: "无法重置模型:{message}",
|
||||
resetFailedGeneric: "无法重置模型 —— 请重试。",
|
||||
resetToDefault: "重置为默认",
|
||||
changeTitle: "点击更换此对话的模型",
|
||||
changeAria: "更换此对话的模型",
|
||||
},
|
||||
reasoning: {
|
||||
aria: "推理强度",
|
||||
title: "主模型的推理强度",
|
||||
titleDisabled: "先选择模型,才能覆盖注册表配置的推理强度",
|
||||
auto: "自动",
|
||||
low: "低",
|
||||
medium: "中",
|
||||
high: "高",
|
||||
reset: "推理强度已恢复为注册表默认。",
|
||||
set: "推理强度已设置为 {effort}。",
|
||||
failed: "无法更新推理强度:{message}",
|
||||
failedGeneric: "无法更新推理强度 —— 请重试。",
|
||||
},
|
||||
sampling: {
|
||||
aria: "采样覆盖",
|
||||
title: "采样覆盖:temperature 和 top_p 互斥",
|
||||
titleDisabled: "先选择模型,才能覆盖注册表配置的采样",
|
||||
modeDefault: "默认",
|
||||
temperature: "温度",
|
||||
topP: "Top P",
|
||||
unset: "未设置",
|
||||
exactValue: "{label} 精确值",
|
||||
clickToType: "点击数值以输入精确的 {label}",
|
||||
sliderTitleDisabled: "先选择模型,才能覆盖注册表配置的 {label}",
|
||||
sliderTitleDefault:
|
||||
"{label}:注册表默认 {value} —— 拖动以覆盖,或点击数值直接输入",
|
||||
sliderTitleOverride:
|
||||
"{label} 覆盖 —— 点击数值可输入精确值;清空后回车重置",
|
||||
reset: "采样已恢复为注册表默认。",
|
||||
temperatureSet: "温度已设置为 {value}(top_p 已省略)。",
|
||||
topPSet: "Top P 已设置为 {value}(temperature 已省略)。",
|
||||
failed: "无法更新采样:{message}",
|
||||
failedGeneric: "无法更新采样 —— 请重试。",
|
||||
},
|
||||
usage: {
|
||||
tokensLoading: "Token 加载中",
|
||||
tokensUnavailable: "Token 不可用",
|
||||
tokensLabel: "{count} tokens",
|
||||
viewAria: "查看 token 用量:{label}",
|
||||
},
|
||||
agents: {
|
||||
runningTitle:
|
||||
"{count, plural, other {# 个后台代理正在运行 —— 点击查看}}",
|
||||
runningAria: "{count, plural, other {# 个后台代理正在运行 —— 查看}}",
|
||||
runningLabel: "{count, plural, other {# 个代理}}",
|
||||
autoReported: "已将 {agent} 自动回报给主对话。",
|
||||
},
|
||||
queue: {
|
||||
label: "排队消息",
|
||||
filesAttached: "{count, plural, other {# 个文件}}",
|
||||
reorderAria: "调整第 {index} 条(共 {total} 条)排队消息的顺序",
|
||||
reorderTitle: "拖拽排序,或使用方向键",
|
||||
steerAria: "用这条消息优先引导",
|
||||
steerTitle: "下一条优先发送,不打断当前回合",
|
||||
steer: "引导",
|
||||
steerToast: "这条消息将在当前回合结束后优先执行。",
|
||||
removeAria: "移除排队消息",
|
||||
removeTitle: "从队列中移除",
|
||||
moreAria: "更多排队消息操作",
|
||||
moreTitle: "更多操作",
|
||||
closeQueue: "关闭队列",
|
||||
},
|
||||
tasks: {
|
||||
allCompleted: "所有任务已完成",
|
||||
progress: "任务 {current} / {total}",
|
||||
filesState: "文件(状态)",
|
||||
tasks: "任务",
|
||||
pending: "待处理",
|
||||
inProgress: "进行中",
|
||||
completed: "已完成",
|
||||
},
|
||||
workspace: {
|
||||
files: "文件",
|
||||
openFiles: "打开对话文件",
|
||||
closeFiles: "关闭对话文件",
|
||||
},
|
||||
message: {
|
||||
copy: "复制消息",
|
||||
copied: "已复制",
|
||||
copyFailed: "无法复制到剪贴板。",
|
||||
thinking: "思考",
|
||||
backgroundAgentReported: "后台代理已回报",
|
||||
},
|
||||
subAgent: {
|
||||
badge: "子代理",
|
||||
input: "输入",
|
||||
steps: "步骤",
|
||||
output: "输出",
|
||||
},
|
||||
toolCall: {
|
||||
arguments: "参数",
|
||||
result: "结果",
|
||||
noResultYet: "暂无结果",
|
||||
},
|
||||
toolApproval: {
|
||||
title: "需要批准",
|
||||
tool: "工具",
|
||||
editArguments: "编辑参数",
|
||||
arguments: "参数",
|
||||
rejectionMessage: "拒绝理由(可选)",
|
||||
rejectionPlaceholder: "说明拒绝此操作的原因…",
|
||||
saveApprove: "保存并批准",
|
||||
saving: "保存中…",
|
||||
confirmReject: "确认拒绝",
|
||||
rejecting: "拒绝中…",
|
||||
reject: "拒绝",
|
||||
edit: "编辑",
|
||||
approveOnce: "仅批准此次",
|
||||
approving: "批准中…",
|
||||
approveAuto: "自动批准此对话",
|
||||
},
|
||||
askUser: {
|
||||
title: "EvoScientist 需要你的输入",
|
||||
other: "其他…",
|
||||
answerPlaceholder: "输入你的回答…",
|
||||
answerPlaceholderOptional: "输入你的回答…(可选)",
|
||||
submit: "提交",
|
||||
submitting: "提交中…",
|
||||
},
|
||||
compaction: {
|
||||
title: "对话已压缩",
|
||||
summarized: "{count, plural, other {已总结 # 条较早消息}}",
|
||||
tokenStats: "≈{before} → ≈{after} tokens",
|
||||
detailsWithoutBudget:
|
||||
"提示词大小(估算):压缩前 ≈{before} tokens,压缩后 ≈{after} tokens。下一次模型调用完成后,确切用量会显示在 token 消耗中。",
|
||||
detailsWithBudget:
|
||||
"提示词大小(估算):压缩前 ≈{before} tokens,压缩后 ≈{after} tokens · 硬消息预算 ≈{budget} tokens。下一次模型调用完成后,确切用量会显示在 token 消耗中。",
|
||||
},
|
||||
actionGroup: {
|
||||
ariaLabel: "助手工作过程",
|
||||
working: "执行中",
|
||||
approvalRequired: "需要批准",
|
||||
failed: "{total} 项操作中有 {failed} 项失败",
|
||||
workingWithCount: "执行中 · {total} 项操作",
|
||||
completed: "{total} 项操作已完成",
|
||||
collapse: "收起",
|
||||
collapseCount: "收起 {total} 项操作",
|
||||
collapseGroup: "收起 {summary}",
|
||||
expandGroup: "展开 {summary}",
|
||||
},
|
||||
contextUsage: {
|
||||
label: "ctx {percent}% · {used}/{limit}",
|
||||
tooltipMeasured:
|
||||
"上次模型调用使用了 {limit} 中的 {used} tokens(实测)· 约 {soft} 时自动压缩消息。",
|
||||
tooltipEstimate:
|
||||
"上下文占用(估算):{limit} 中的 {used} tokens · 约 {soft} 时自动压缩消息。",
|
||||
},
|
||||
toolLabels: {
|
||||
unknownTool: "未知工具",
|
||||
adaptiveToolSelection: "自适应工具选择",
|
||||
reflection: "反思",
|
||||
readingMemory: "读取记忆",
|
||||
updatingMemory: "更新记忆",
|
||||
skill: "技能:{name}",
|
||||
execute: "运行命令",
|
||||
grep: "搜索文件",
|
||||
readFile: "读取文件",
|
||||
writeFile: "写入文件",
|
||||
editFile: "编辑文件",
|
||||
listFiles: "列出文件",
|
||||
task: "运行子代理",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,62 @@
|
||||
export default {
|
||||
threadList: {},
|
||||
threadList: {
|
||||
newChat: "新对话",
|
||||
researchSkills: "研究技能",
|
||||
evoMemory: "EvoMemory",
|
||||
memoryUpdatesTitle:
|
||||
"{count, plural, other {自上次查看以来有 # 个记忆文件更新}}",
|
||||
memoryUpdatesAria: "{count, plural, other {# 条记忆更新}}",
|
||||
scheduled: "定时任务",
|
||||
searchPlaceholder: "搜索研究…",
|
||||
searchAria: "搜索研究",
|
||||
clearSearch: "清除研究搜索",
|
||||
recents: "最近",
|
||||
filterAll: "全部",
|
||||
filterActive: "活跃",
|
||||
filterIdle: "空闲",
|
||||
filterBusy: "忙碌",
|
||||
filterAttention: "需要注意",
|
||||
filterInterrupted: "已中断",
|
||||
filterError: "错误",
|
||||
closeNavigation: "关闭导航",
|
||||
closeResearch: "关闭研究",
|
||||
loadFailed: "加载研究失败",
|
||||
empty: "暂无研究",
|
||||
noMatch: "没有匹配搜索的研究。",
|
||||
pinnedSection: "研究",
|
||||
groups: {
|
||||
requiringAttention: "需要注意",
|
||||
today: "今天",
|
||||
yesterday: "昨天",
|
||||
thisWeek: "本周",
|
||||
older: "更早",
|
||||
},
|
||||
loadMore: "加载更多",
|
||||
unknownTime: "未知时间",
|
||||
pin: "置顶",
|
||||
unpin: "取消置顶",
|
||||
pinAria: "置顶“{title}”",
|
||||
unpinAria: "取消置顶“{title}”",
|
||||
rename: "重命名",
|
||||
renameAria: "重命名“{title}”",
|
||||
exportJson: "导出 JSON",
|
||||
exportAria: "将“{title}”导出为 JSON",
|
||||
deleteAria: "删除“{title}”",
|
||||
renameDialog: {
|
||||
title: "重命名研究",
|
||||
description: "为这个对话设置自定义标题。",
|
||||
placeholder: "输入标题…",
|
||||
saving: "保存中…",
|
||||
},
|
||||
deleteDialog: {
|
||||
title: "删除这项研究?",
|
||||
description: "“{title}” 将被永久删除,此操作无法撤销。",
|
||||
deleting: "删除中…",
|
||||
},
|
||||
renameFailed: "无法重命名 —— 请重试。",
|
||||
deleteFailed: "无法删除 —— 请重试。",
|
||||
pinFailed: "无法置顶 —— 请重试。",
|
||||
unpinFailed: "无法取消置顶 —— 请重试。",
|
||||
exportFailed: "无法导出 —— 请重试。",
|
||||
},
|
||||
};
|
||||
|
||||
+68
-17
@@ -41,15 +41,54 @@ function asArgsObject(args: unknown): Record<string, unknown> {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function formatToolLabel(name: string, args?: unknown): string {
|
||||
if (!name) return "Unknown Tool";
|
||||
/** Translated strings for {@link formatToolLabelWith}; matches the
|
||||
* `chat.toolLabels.*` catalog keys. */
|
||||
export interface ToolLabelStrings {
|
||||
unknownTool: string;
|
||||
adaptiveToolSelection: string;
|
||||
reflection: string;
|
||||
readingMemory: string;
|
||||
updatingMemory: string;
|
||||
/** Skill label; `{name}` is the skill directory name. */
|
||||
skill: (name: string) => string;
|
||||
execute: string;
|
||||
grep: string;
|
||||
readFile: string;
|
||||
writeFile: string;
|
||||
editFile: string;
|
||||
listFiles: string;
|
||||
task: string;
|
||||
}
|
||||
|
||||
const EN_TOOL_LABELS: ToolLabelStrings = {
|
||||
unknownTool: "Unknown Tool",
|
||||
adaptiveToolSelection: "Adaptive tool selection",
|
||||
reflection: "Reflection",
|
||||
readingMemory: "Reading memory",
|
||||
updatingMemory: "Updating memory",
|
||||
skill: (name) => `Skill: ${name}`,
|
||||
execute: "Running command",
|
||||
grep: "Searching files",
|
||||
readFile: "Reading file",
|
||||
writeFile: "Writing file",
|
||||
editFile: "Editing file",
|
||||
listFiles: "Listing files",
|
||||
task: "Running subagent",
|
||||
};
|
||||
|
||||
export function formatToolLabelWith(
|
||||
labels: ToolLabelStrings,
|
||||
name: string,
|
||||
args?: unknown
|
||||
): string {
|
||||
if (!name) return labels.unknownTool;
|
||||
|
||||
// The tool-selector middleware's internal structured-output call.
|
||||
if (name === "ToolSelectionResponse") return "Adaptive tool selection";
|
||||
if (name === "ToolSelectionResponse") return labels.adaptiveToolSelection;
|
||||
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
if (nameLower === "think_tool") return "Reflection";
|
||||
if (nameLower === "think_tool") return labels.reflection;
|
||||
|
||||
if (
|
||||
nameLower === "read_file" ||
|
||||
@@ -59,30 +98,42 @@ export function formatToolLabel(name: string, args?: unknown): string {
|
||||
const a = asArgsObject(args);
|
||||
const path = String(a.path ?? a.file_path ?? "");
|
||||
if (isMemoryPath(path)) {
|
||||
return nameLower === "read_file" ? "Reading memory" : "Updating memory";
|
||||
return nameLower === "read_file"
|
||||
? labels.readingMemory
|
||||
: labels.updatingMemory;
|
||||
}
|
||||
if (nameLower === "read_file") {
|
||||
const skill = skillNameFromPath(path);
|
||||
if (skill) return `Skill: ${skill}`;
|
||||
if (skill) return labels.skill(skill);
|
||||
}
|
||||
}
|
||||
|
||||
const commonLabels: Record<string, string> = {
|
||||
execute: "Running command",
|
||||
grep: "Searching files",
|
||||
read_file: "Reading file",
|
||||
write_file: "Writing file",
|
||||
edit_file: "Editing file",
|
||||
ls: "Listing files",
|
||||
list_files: "Listing files",
|
||||
task: "Running subagent",
|
||||
};
|
||||
if (commonLabels[nameLower]) return commonLabels[nameLower];
|
||||
switch (nameLower) {
|
||||
case "execute":
|
||||
return labels.execute;
|
||||
case "grep":
|
||||
return labels.grep;
|
||||
case "read_file":
|
||||
return labels.readFile;
|
||||
case "write_file":
|
||||
return labels.writeFile;
|
||||
case "edit_file":
|
||||
return labels.editFile;
|
||||
case "ls":
|
||||
case "list_files":
|
||||
return labels.listFiles;
|
||||
case "task":
|
||||
return labels.task;
|
||||
}
|
||||
|
||||
// Everything else keeps its raw tool name.
|
||||
return name;
|
||||
}
|
||||
|
||||
export function formatToolLabel(name: string, args?: unknown): string {
|
||||
return formatToolLabelWith(EN_TOOL_LABELS, name, args);
|
||||
}
|
||||
|
||||
function compactValue(value: unknown): string {
|
||||
if (typeof value === "string") return value.replace(/\s+/g, " ").trim();
|
||||
if (value === null || value === undefined) return "";
|
||||
|
||||
Reference in New Issue
Block a user