import {
ActionBarMorePrimitive,
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
SuggestionPrimitive,
ThreadPrimitive,
useAui,
useAuiState,
} from "@assistant-ui/react";
import {
ArrowDownIcon,
ArrowUpIcon,
BookOpenIcon,
BrainIcon,
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
FileTextIcon,
ListIcon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
SearchIcon,
SquareIcon,
WrenchIcon,
} from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type { ComponentProps, FC, KeyboardEvent, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import { useActivityPanel } from "@/components/assistant-ui/activity-panel";
import {
ComposerAddAttachment,
ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/attachment";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { Reasoning } from "@/components/assistant-ui/reasoning";
import {
fetchLatestRunStatus,
toReplayRepository,
} from "@/components/assistant-ui/thread-list";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { cn } from "@/lib/utils";
type ComposerInsertEvent = CustomEvent<{ text: string }>;
type SkillSummary = {
name: string;
description?: string;
when_to_use?: string;
enabled?: boolean;
configurable?: boolean;
};
type SkillSyncResponse = {
skills?: SkillSummary[];
updated?: boolean;
before?: string;
after?: string;
changed_files?: string[];
message?: string;
error?: string;
detail?: string;
};
type SlashCommandSummary = {
names: string[];
primary: string;
description?: string;
webui_supported?: boolean;
};
type ClawState = {
model?: string;
active_session_id?: string | null;
};
type ClawStoredSession = {
session_id?: string;
model?: string;
};
type ModelOption = {
id: string;
provider?: string;
model_type?: string;
};
type ContextBudget = {
model?: string;
context_window_tokens?: number;
projected_input_tokens?: number;
message_tokens?: number;
chat_overhead_tokens?: number;
reserved_output_tokens?: number;
reserved_compaction_buffer_tokens?: number;
reserved_schema_tokens?: number;
hard_input_limit_tokens?: number;
soft_input_limit_tokens?: number;
overflow_tokens?: number;
soft_overflow_tokens?: number;
exceeds_hard_limit?: boolean;
exceeds_soft_limit?: boolean;
token_counter_backend?: string;
token_counter_source?: string;
token_counter_accurate?: boolean;
};
function scheduleSessionListRefresh() {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event("claw-sessions-changed"));
for (const delay of [300, 1000, 2500]) {
window.setTimeout(() => {
window.dispatchEvent(new Event("claw-sessions-changed"));
}, delay);
}
}
export const Thread: FC = () => {
useRefreshReplayedRun();
return (
s.thread.isEmpty}>
{() => }
);
};
function useRefreshReplayedRun() {
const { replaySession } = useClawSessionReplay();
const replayedRun = useReplayedRunState();
useEffect(() => {
if (!replayedRun.active || !replayedRun.sessionId) return;
const activeSessionId = replayedRun.sessionId;
let cancelled = false;
async function refreshIfFinished() {
const runStatus = await fetchLatestRunStatus(activeSessionId);
if (cancelled) return;
if (runStatus?.status === "queued" || runStatus?.status === "running") {
return;
}
const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
replaySession(
activeSessionId,
toReplayRepository(payload, activeSessionId, runStatus),
);
window.dispatchEvent(new Event("claw-sessions-changed"));
}
refreshIfFinished();
const interval = window.setInterval(refreshIfFinished, 3000);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [replayedRun.active, replayedRun.sessionId, replaySession]);
}
function useReplayedRunState() {
const active = useAuiState((s) =>
s.thread.messages.some((message) => isReplayRunMessageId(message.id)),
);
const replaySessionId = useAuiState((s) => {
const replayRunMessage = s.thread.messages.find((message) =>
isReplayRunMessageId(message.id),
);
return normalizeSessionId(
readMetadataValue(replayRunMessage?.metadata, "sessionId"),
);
});
const latestSessionId = useAuiState((s) =>
normalizeSessionId(
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
),
);
const runId = useAuiState((s) => {
const replayRunMessage = s.thread.messages.find((message) =>
isReplayRunMessageId(message.id),
);
return normalizeSessionId(
readMetadataValue(replayRunMessage?.metadata, "runId"),
);
});
const localSessionId =
typeof window !== "undefined"
? normalizeSessionId(window.localStorage.getItem("claw.activeSessionId"))
: null;
const sessionId = replaySessionId ?? latestSessionId ?? localSessionId;
return useMemo(
() => ({ active, sessionId, runId }),
[active, sessionId, runId],
);
}
async function cancelLatestRun(sessionId: string, runId?: string | null) {
await fetch("/api/claw/runs/cancel", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
...(runId ? { run_id: runId } : {}),
}),
});
}
const ChatTopActions: FC = () => {
const { openFiles } = useActivityPanel();
const latestSessionId = useAuiState((s) =>
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
);
return (
);
};
const ThreadMessage: FC = () => {
const role = useAuiState((s) => s.message.role);
const isEditing = useAuiState((s) => s.message.composer.isEditing);
if (isEditing) return ;
if (role === "user") return ;
return ;
};
const ThreadScrollToBottom: FC = () => {
return (
);
};
const ThreadWelcome: FC = () => {
return (
);
};
const ThreadSuggestions: FC = () => {
return (
{() => }
);
};
const ThreadSuggestionItem: FC = () => {
return (
);
};
const Composer: FC = () => {
const replayedRun = useReplayedRunState();
return (
);
};
const ComposerAction: FC = () => {
const runtimeRunning = useAuiState((s) => s.thread.isRunning);
const replayedRun = useReplayedRunState();
const cancelSessionId = replayedRun.sessionId;
return (
{!runtimeRunning && !replayedRun.active ? (
) : null}
{runtimeRunning ? (
) : null}
{!runtimeRunning && replayedRun.active ? (
) : null}
);
};
const ReplayedRunCancelButton: FC<{
sessionId: string | null;
runId: string | null;
}> = ({ sessionId, runId }) => {
const { replaySession } = useClawSessionReplay();
const [cancelling, setCancelling] = useState(false);
return (
);
};
const ComposerContextStatus: FC = () => {
const status = useComposerContextStatus();
const totalTokens = status.contextBudget?.projected_input_tokens ?? 0;
const limit = status.contextBudget?.context_window_tokens ?? 0;
const percent = limit
? Math.min(100, Math.round((totalTokens / limit) * 100))
: 0;
return (
);
};
function ContextWindowButton({
percent,
totalTokens,
limit,
contextBudget,
}: {
percent: number;
totalTokens: number;
limit: number;
contextBudget?: ContextBudget;
}) {
const ringStyle = {
background: `conic-gradient(var(--primary) ${percent * 3.6}deg, var(--muted) 0deg)`,
};
return (
背景信息窗口:
{percent}% 已用(剩余 {Math.max(0, 100 - percent)}%)
已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "}
{formatCompactTokenCount(limit)}
背景信息窗口:
{percent}% 已用(剩余 {Math.max(0, 100 - percent)}%)
已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "}
{formatCompactTokenCount(limit)}
软阈值:
{formatCompactTokenCount(
contextBudget?.soft_input_limit_tokens ?? 0,
)}
硬阈值:
{formatCompactTokenCount(
contextBudget?.hard_input_limit_tokens ?? 0,
)}
保留输出:
{formatCompactTokenCount(
contextBudget?.reserved_output_tokens ?? 0,
)}
;压缩缓冲:
{formatCompactTokenCount(
contextBudget?.reserved_compaction_buffer_tokens ?? 0,
)}
计数器:{contextBudget?.token_counter_backend ?? "unknown"}
{contextBudget?.token_counter_accurate ? "(精确)" : "(估算)"}
);
}
function ModelPickerButton({
status,
}: {
status: {
model?: string;
models: ModelOption[];
setModel: (model: string) => Promise;
};
}) {
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredModels = useMemo(() => {
if (!normalizedQuery) return status.models;
return status.models.filter((model) => {
const provider = model.provider ?? modelProvider(model.id);
return (
model.id.toLowerCase().includes(normalizedQuery) ||
provider.toLowerCase().includes(normalizedQuery)
);
});
}, [normalizedQuery, status.models]);
return (
切换模型
setQuery(event.target.value)}
placeholder="搜索模型或来源"
className="h-8 w-full rounded-lg border bg-background pr-2.5 pl-8 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
/>
{status.models.length ? (
{filteredModels.length ? (
filteredModels.map((model) => (
))
) : (
没有匹配的模型
)}
) : (
模型列表读取中
)}
);
}
function useComposerContextStatus() {
const isRunning = useAuiState((s) => s.thread.isRunning);
const latestSessionId = useAuiState((s) =>
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
);
const wasRunningRef = useRef(false);
const [status, setStatus] = useState<{
model?: string;
contextBudget?: ContextBudget;
sessionId?: string | null;
models: ModelOption[];
setModel: (model: string) => Promise;
}>({
models: [],
setModel: async () => {},
});
useEffect(() => {
const cleanLatestSessionId = normalizeSessionId(latestSessionId);
if (cleanLatestSessionId) {
window.localStorage.setItem("claw.activeSessionId", cleanLatestSessionId);
window.setTimeout(() => {
window.dispatchEvent(new Event("claw-sessions-changed"));
}, 0);
}
}, [latestSessionId]);
useEffect(() => {
if (wasRunningRef.current && !isRunning) {
window.dispatchEvent(new Event("claw-sessions-changed"));
window.setTimeout(() => {
window.dispatchEvent(new Event("claw-sessions-changed"));
}, 1500);
}
wasRunningRef.current = isRunning;
}, [isRunning]);
useEffect(() => {
let cancelled = false;
async function refresh() {
try {
const stateResponse = await fetch("/api/claw/state", {
cache: "no-store",
});
const statePayload = stateResponse.ok
? ((await stateResponse.json()) as ClawState)
: {};
let sessionId = normalizeSessionId(
normalizeSessionId(latestSessionId) ||
normalizeSessionId(
window.localStorage.getItem("claw.activeSessionId"),
) ||
normalizeSessionId(statePayload.active_session_id),
);
if (sessionId)
window.localStorage.setItem("claw.activeSessionId", sessionId);
let sessionPayload: ClawStoredSession | null = null;
let contextBudget: ContextBudget | undefined;
if (sessionId) {
const sessionResponse = await fetch(
`/api/claw/sessions/${sessionId}`,
{
cache: "no-store",
},
);
if (sessionResponse.ok) {
sessionPayload =
(await sessionResponse.json()) as ClawStoredSession;
} else if (sessionResponse.status === 404) {
window.localStorage.removeItem("claw.activeSessionId");
sessionId = null;
}
}
const budgetUrl = new URL(
"/api/claw/context-budget",
window.location.origin,
);
if (sessionId) budgetUrl.searchParams.set("session_id", sessionId);
const budgetResponse = await fetch(budgetUrl, { cache: "no-store" });
contextBudget = budgetResponse.ok
? ((await budgetResponse.json()) as ContextBudget)
: undefined;
if (cancelled) return;
setStatus((current) => ({
model:
statePayload.model ?? contextBudget?.model ?? sessionPayload?.model,
contextBudget,
sessionId,
models: current.models,
setModel: current.setModel,
}));
} catch {
if (!cancelled) {
setStatus((current) => ({
...current,
}));
}
}
}
refresh();
const onRefresh = () => refresh();
window.addEventListener("focus", onRefresh);
window.addEventListener("claw-active-session-cleared", onRefresh);
const interval = window.setInterval(refresh, isRunning ? 2000 : 8000);
return () => {
cancelled = true;
window.removeEventListener("focus", onRefresh);
window.removeEventListener("claw-active-session-cleared", onRefresh);
window.clearInterval(interval);
};
}, [isRunning, latestSessionId]);
useEffect(() => {
let cancelled = false;
async function refreshModels() {
try {
const response = await fetch("/api/claw/models", {
cache: "no-store",
});
const payload = (await response.json()) as { models?: ModelOption[] };
if (!cancelled && response.ok) {
setStatus((current) => ({
...current,
models: Array.isArray(payload.models) ? payload.models : [],
}));
}
} catch {
if (!cancelled) {
setStatus((current) => ({ ...current, models: [] }));
}
}
}
refreshModels();
return () => {
cancelled = true;
};
}, []);
const setModel = useCallback(async (model: string) => {
const response = await fetch("/api/claw/state", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model }),
});
const payload = (await response.json()) as ClawState;
if (!response.ok) return;
setStatus((current) => ({
...current,
model: payload.model ?? model,
contextBudget: undefined,
}));
}, []);
useEffect(() => {
setStatus((current) => ({ ...current, setModel }));
}, [setModel]);
return status;
}
function findLatestMessageMetadataValue(
messages: readonly unknown[],
key: string,
) {
for (const message of [...messages].reverse()) {
if (!message || typeof message !== "object") continue;
const metadata = (message as { metadata?: unknown }).metadata;
const value = readMetadataValue(metadata, key);
if (value !== undefined) return value;
}
return undefined;
}
function readMetadataValue(metadata: unknown, key: string): unknown {
if (!metadata || typeof metadata !== "object") return undefined;
const record = metadata as Record;
if (record[key] !== undefined) return record[key];
const custom = record.custom;
if (custom && typeof custom === "object") {
return (custom as Record)[key];
}
return undefined;
}
function normalizeSessionId(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
}
function formatCompactTokenCount(value: number) {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return `${value}`;
}
function compactModelLabel(model?: string) {
if (!model) return "模型";
const value = model.includes("/") ? model.split("/").at(-1) || model : model;
if (value.toLowerCase().startsWith("mimo-v2.5")) return "MiMo 2.5";
if (value.toLowerCase().startsWith("mimo-v2")) return "MiMo 2";
if (value.toLowerCase().startsWith("deepseek")) return "DeepSeek";
if (value.toLowerCase().startsWith("qwen")) return "Qwen";
if (value.toLowerCase().startsWith("minimax")) return "MiniMax";
return value;
}
function modelProvider(model: string) {
if (model.includes("/")) return model.split("/", 1)[0] || "unknown";
return "default";
}
const ComposerAssistButtons: FC = () => {
return (
}
triggerLabel=""
items={TOOL_PROMPTS}
/>
);
};
const TOOL_PROMPTS = [
{
title: "data_agent_profile_router_sessions",
description: "查看 parquet 线上 session 数据字段、规模和样例。",
prompt:
"请优先使用 data_agent_profile_router_sessions 了解我指定的 router_session_parquet 数据目录,再给出可用字段和初步挖掘建议。",
},
{
title: "data_agent_search_router_sessions",
description: "用关键词、正则、domain、device 等条件筛选候选。",
prompt:
"请优先使用 data_agent_search_router_sessions 按我的条件筛选线上候选,并说明筛选策略、命中数量和样例质量。",
},
{
title: "data_agent_normalize_dataset_draft",
description: "把自然语言 draft 转换为 canonical records。",
prompt:
"请在我确认数据内容后,使用 data_agent_normalize_dataset_draft 把 draft 转换成 canonical records。",
},
{
title: "data_agent_validate_dataset_records",
description: "校验 canonical records 是否符合结构约束。",
prompt:
"请使用 data_agent_validate_dataset_records 校验当前 canonical records,并解释每类错误应该如何修复。",
},
];
function PromptInsertDialog({
title,
triggerIcon,
triggerLabel,
items,
}: {
title: string;
triggerIcon: ReactNode;
triggerLabel: string;
items: { title: string; description: string; prompt: string }[];
}) {
const [open, setOpen] = useState(false);
return (
);
}
function SlashCommandDialog() {
const [open, setOpen] = useState(false);
const [commands, setCommands] = useState([]);
const [status, setStatus] = useState(
"点击后读取当前后端 slash command 列表。",
);
async function loadCommands() {
setStatus("正在读取 slash command 列表...");
try {
const response = await fetch("/api/claw/slash-commands", {
cache: "no-store",
});
const payload = (await response.json()) as
| SlashCommandSummary[]
| { error?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error(
Array.isArray(payload) ? "读取失败" : (payload.error ?? "读取失败"),
);
}
setCommands(payload);
setStatus(payload.length ? "" : "当前没有可用 slash command。");
} catch (err) {
setStatus(err instanceof Error ? err.message : "读取 slash command 失败");
}
}
return (
);
}
function SkillInsertDialog() {
const [open, setOpen] = useState(false);
const [skills, setSkills] = useState([]);
const [status, setStatus] = useState("点击后读取当前后端 Skill 列表。");
const [updatingSkill, setUpdatingSkill] = useState(null);
const [syncingSkills, setSyncingSkills] = useState(false);
const [bulkUpdatingSkills, setBulkUpdatingSkills] = useState(false);
const configurableSkills = skills.filter(
(skill) => skill.configurable !== false,
);
const allSkillsEnabled =
configurableSkills.length > 0 &&
configurableSkills.every((skill) => skill.enabled !== false);
async function loadSkills() {
setStatus("正在读取 Skill 列表...");
try {
const response = await fetch("/api/claw/skills", { cache: "no-store" });
const payload = (await response.json()) as
| SkillSummary[]
| { error?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error(
Array.isArray(payload) ? "读取失败" : (payload.error ?? "读取失败"),
);
}
setSkills(payload);
setStatus(payload.length ? "" : "当前没有可用 Skill。");
} catch (err) {
setStatus(err instanceof Error ? err.message : "读取 Skill 失败");
}
}
async function syncSkills() {
setSyncingSkills(true);
setStatus("正在从 git 同步 Skill...");
try {
const response = await fetch("/api/claw/skills", {
method: "POST",
cache: "no-store",
});
const payload = (await response.json()) as SkillSyncResponse;
if (!response.ok || !Array.isArray(payload.skills)) {
throw new Error(payload.detail ?? payload.error ?? "同步 Skill 失败");
}
setSkills(payload.skills);
const changedCount = payload.changed_files?.length ?? 0;
if (payload.updated) {
const shortHash = payload.after
? payload.after.slice(0, 7)
: "最新提交";
setStatus(`Skill 已更新到 ${shortHash},变更 ${changedCount} 个文件。`);
} else {
setStatus("Skill 已是最新。");
}
} catch (err) {
setStatus(err instanceof Error ? err.message : "同步 Skill 失败");
} finally {
setSyncingSkills(false);
}
}
async function setSkillEnabled(skill: SkillSummary, enabled: boolean) {
if (skill.configurable === false) return;
const previous = skills;
setUpdatingSkill(skill.name);
setSkills((items) =>
items.map((item) =>
item.name === skill.name ? { ...item, enabled } : item,
),
);
try {
const response = await fetch("/api/claw/skills", {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ skill: skill.name, enabled }),
});
const payload = (await response.json()) as
| SkillSummary[]
| { error?: string; detail?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error(
Array.isArray(payload)
? "更新失败"
: (payload.detail ?? payload.error ?? "更新失败"),
);
}
setSkills(payload);
setStatus(enabled ? `已启用 ${skill.name}` : `已停用 ${skill.name}`);
} catch (err) {
setSkills(previous);
setStatus(err instanceof Error ? err.message : "更新 Skill 失败");
} finally {
setUpdatingSkill(null);
}
}
async function setAllSkillsEnabled(enabled: boolean) {
if (!configurableSkills.length) return;
const previous = skills;
setBulkUpdatingSkills(true);
setSkills((items) =>
items.map((item) =>
item.configurable === false ? item : { ...item, enabled },
),
);
try {
const response = await fetch("/api/claw/skills", {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ apply_all: true, enabled }),
});
const payload = (await response.json()) as
| SkillSummary[]
| { error?: string; detail?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error(
Array.isArray(payload)
? "更新失败"
: (payload.detail ?? payload.error ?? "更新失败"),
);
}
setSkills(payload);
setStatus(enabled ? "已启用全部 Skill。" : "已停用全部可选 Skill。");
} catch (err) {
setSkills(previous);
setStatus(err instanceof Error ? err.message : "批量更新 Skill 失败");
} finally {
setBulkUpdatingSkills(false);
}
}
return (
);
}
function insertComposerText(text: string) {
window.dispatchEvent(
new CustomEvent("claw-composer-insert", {
detail: { text },
}),
);
}
const MessageError: FC = () => {
return (
);
};
const AssistantMessage: FC = () => {
const ACTION_BAR_PT = "pt-1.5";
const ACTION_BAR_HEIGHT = `-mb-7.5 min-h-7.5 ${ACTION_BAR_PT}`;
const messageId = useAuiState((s) => s.message.id);
return (
{
if (part.type === "reasoning" || part.type === "tool-call")
return ["group-chain"];
return null;
}}
>
{({ part }) => {
switch (part.type) {
case "group-chain":
return (
);
case "text":
return (
);
case "reasoning":
return ;
case "tool-call":
return part.toolUI ?? ;
default:
return null;
}
}}
);
};
const ActivityChainSummary: FC<{
messageId: string;
partIndex: number | undefined;
}> = ({ messageId, partIndex }) => {
const { openItem } = useActivityPanel();
const startedAtRef = useRef(Date.now());
const metadataRunStartedAtMs = useAuiState((s) => {
const value = readMetadataValue(s.message.metadata, "runStartedAtMs");
return typeof value === "number" && Number.isFinite(value) ? value : null;
});
const contentRunStartedAtMs = useAuiState((s) =>
findNumberInActivityParts(s.message.content, "runStartedAtMs"),
);
const storedElapsedMs = useAuiState((s) => {
const value = readMetadataValue(s.message.metadata, "elapsedMs");
const contentValue = findElapsedMsInActivityParts(s.message.content);
if (typeof value !== "number" || !Number.isFinite(value)) {
return contentValue;
}
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.round(value))
: null;
});
const [elapsedMs, setElapsedMs] = useState(storedElapsedMs);
const label = useAuiState((s) => {
const message = s.message;
const status =
message.status?.type === "running" || isReplayRunMessageId(messageId)
? "running"
: message.status?.type;
return summarizeActivityChainLabel(message.content, status, elapsedMs);
});
const active = useAuiState((s) => {
const message = s.message;
return (
message.status?.type === "running" || isReplayRunMessageId(messageId)
);
});
useEffect(() => {
if (!active) {
setElapsedMs(storedElapsedMs);
return;
}
const startedAt =
metadataRunStartedAtMs ?? contentRunStartedAtMs ?? startedAtRef.current;
setElapsedMs(Math.max(0, Date.now() - startedAt));
const timer = window.setInterval(() => {
setElapsedMs(Math.max(0, Date.now() - startedAt));
}, 1000);
return () => window.clearInterval(timer);
}, [active, metadataRunStartedAtMs, contentRunStartedAtMs, storedElapsedMs]);
return (
);
};
function isReplayRunMessageId(messageId: string) {
return messageId.includes("-run-");
}
type ActivitySummaryPart = {
type: string;
text?: string;
result?: unknown;
args?: unknown;
toolName?: string;
runStartedAtMs?: unknown;
elapsedMs?: unknown;
};
function findNumberInActivityParts(
content: readonly ActivitySummaryPart[],
key: keyof ActivitySummaryPart,
) {
for (const part of content) {
const value = part[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return null;
}
function findElapsedMsInActivityParts(content: readonly ActivitySummaryPart[]) {
for (const part of content) {
if (typeof part.elapsedMs === "number" && Number.isFinite(part.elapsedMs)) {
return Math.max(0, Math.round(part.elapsedMs));
}
const args = part.args;
if (!args || typeof args !== "object" || Array.isArray(args)) continue;
const value = (args as Record).__claw_elapsed_ms;
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(0, Math.round(value));
}
}
return null;
}
function summarizeActivityChainLabel(
content: readonly ActivitySummaryPart[],
status: string | undefined,
elapsedMs: number | null = null,
) {
const toolParts = content.filter((part) => part.type === "tool-call");
const runningTool = toolParts.findLast((part) => part.result === undefined);
const activeTool = runningTool ?? toolParts.at(-1);
const reasoningText =
content.find((part) => part.type === "reasoning")?.text ?? "";
const lastReasoningLine = reasoningText
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.at(-1);
const stageNote = activeTool ? getToolStageNote(activeTool.args) : "";
const activeDetail =
stageNote ||
(activeTool?.toolName ? `调用 ${activeTool.toolName}` : "") ||
(isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine);
if (status === "running") {
const elapsed = `用时 ${formatActivityDuration(elapsedMs ?? 0)}`;
if (activeDetail) {
return `${compactActivityLabel(activeDetail, 15)},${elapsed}`;
}
return `思考中,${elapsed}`;
}
const label = compactActivityLabel(
activeDetail || lastReasoningLine || "思考完成",
15,
);
const finalDuration = extractDurationLabel(lastReasoningLine);
const elapsed =
finalDuration ||
(elapsedMs === null ? "" : formatActivityDuration(elapsedMs));
return elapsed ? `${label},用时 ${elapsed}` : label;
}
function getToolStageNote(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const note = (value as Record).__claw_stage_note;
return typeof note === "string" ? note.trim() : "";
}
function isGenericActivityLine(value?: string) {
if (!value) return true;
return (
value.startsWith("思考中") ||
value.startsWith("思考并执行完成") ||
value.endsWith("输出中...")
);
}
function extractDurationLabel(value?: string) {
if (!value) return "";
const match = value.match(/用时\s*([^。,.,\s]+)/);
return match?.[1] ?? "";
}
function formatActivityDuration(ms: number) {
const safeMs = Math.max(0, Math.round(ms));
if (safeMs < 1000) return `${safeMs}ms`;
const totalSeconds = Math.round(safeMs / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}min${seconds}s` : `${minutes}min`;
}
function compactActivityLabel(label: string, maxLength = 15) {
const normalized = label.replace(/\s+/g, " ").trim();
const progress = normalized.match(
/^进度[::]\s*批\s*(\d+)\s*\/\s*(\d+)(?:[,,]\s*已生成\s*(\d+)\s*\/\s*(\d+))?/,
);
if (progress) {
const batch = `批${progress[1]}/${progress[2]}`;
const count =
progress[3] && progress[4] ? ` 已${progress[3]}/${progress[4]}` : "";
const compactProgress = `${batch}${count}`;
if (compactProgress.length <= maxLength) return compactProgress;
return batch;
}
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, Math.max(1, maxLength - 1))}…`;
}
const AssistantActionBar: FC = () => {
return (
s.message.isCopied}>
!s.message.isCopied}>
Export as Markdown
);
};
const UserMessage: FC = () => {
return (
);
};
const UserActionBar: FC = () => {
return (
);
};
const EditComposer: FC = () => {
return (
);
};
const ImeComposerInput: FC> = ({
autoFocus,
onChange,
onCompositionEnd,
onCompositionStart,
onKeyDown,
disabled,
...props
}) => {
const aui = useAui();
const storeText = useAuiState((s) =>
s.composer.isEditing ? s.composer.text : "",
);
const runtimeDisabled = useAuiState(
(s) => s.thread.isDisabled || s.composer.dictation?.inputDisabled,
);
const [localText, setLocalText] = useState(storeText);
const textareaRef = useRef(null);
const isComposingRef = useRef(false);
const justSubmittedRef = useRef(false);
const isDisabled = runtimeDisabled || disabled;
useEffect(() => {
if (!isComposingRef.current) {
if (justSubmittedRef.current && storeText) return;
if (!storeText) justSubmittedRef.current = false;
setLocalText(storeText);
}
}, [storeText]);
useEffect(() => {
if (
!isComposingRef.current &&
localText !== storeText &&
aui.composer().getState().isEditing
) {
aui.composer().setText(localText);
}
}, [aui, localText, storeText]);
useEffect(() => {
if (!autoFocus || isDisabled) return;
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus({ preventScroll: true });
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}, [autoFocus, isDisabled]);
useEffect(() => {
const clearAfterSubmit = () => {
justSubmittedRef.current = true;
setLocalText("");
scheduleSessionListRefresh();
};
const unsubscribeComposer = aui.on("composer.send", clearAfterSubmit);
const unsubscribeRun = aui.on("thread.runStart", clearAfterSubmit);
return () => {
unsubscribeComposer();
unsubscribeRun();
};
}, [aui]);
const syncComposerText = useCallback(
(value: string) => {
if (!aui.composer().getState().isEditing) return;
aui.composer().setText(value);
},
[aui],
);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const handleInsert = (event: Event) => {
const insertText = (event as ComposerInsertEvent).detail?.text;
if (!insertText) return;
const nextText = [localText.trimEnd(), insertText.trim()]
.filter(Boolean)
.join("\n\n");
setLocalText(nextText);
syncComposerText(nextText);
requestAnimationFrame(() => {
textarea.focus({ preventScroll: true });
textarea.setSelectionRange(nextText.length, nextText.length);
});
};
window.addEventListener("claw-composer-insert", handleInsert);
return () =>
window.removeEventListener("claw-composer-insert", handleInsert);
}, [localText, syncComposerText]);
const handleKeyDown = (event: KeyboardEvent) => {
onKeyDown?.(event);
if (event.defaultPrevented || isDisabled) return;
if (
event.nativeEvent.isComposing ||
isComposingRef.current ||
event.key !== "Enter" ||
event.shiftKey
) {
return;
}
const threadState = aui.thread().getState();
if (threadState.isRunning && !threadState.capabilities.queue) return;
event.preventDefault();
syncComposerText(localText);
aui.composer().send();
justSubmittedRef.current = true;
setLocalText("");
};
return (
{
onChange?.(event);
const nextText = event.currentTarget.value;
justSubmittedRef.current = false;
setLocalText(nextText);
const isNativeComposing =
(event.nativeEvent as { isComposing?: boolean }).isComposing === true;
if (!isNativeComposing && !isComposingRef.current) {
syncComposerText(nextText);
}
}}
onCompositionStart={(event) => {
onCompositionStart?.(event);
isComposingRef.current = true;
}}
onCompositionEnd={(event) => {
onCompositionEnd?.(event);
isComposingRef.current = false;
const target = event.currentTarget;
queueMicrotask(() => {
const nextText = target.value;
setLocalText(nextText);
syncComposerText(nextText);
});
}}
onKeyDown={handleKeyDown}
/>
);
};
const BranchPicker: FC = ({
className,
...rest
}) => {
return (
/
);
};