Refine WebUI commands and data skills

This commit is contained in:
武阳
2026-05-06 19:35:48 +08:00
parent 93a0eb74d7
commit a0da3c2423
13 changed files with 731 additions and 219 deletions
@@ -497,26 +497,39 @@ function collectActivityItems(messages: readonly MessageState[]) {
const id = `${message.id}:${index}`;
if (part.type === "reasoning") {
const status = getPartStatus(message, part);
const summary =
part.text.trim() ||
(status === "running"
? "模型正在整理下一步行动。"
: "思考并执行完成。");
items.push({
id,
kind: "reasoning",
title: status === "running" ? "思考中" : "思考完成",
summary:
part.text.trim() ||
(status === "running"
? "模型正在整理下一步行动。"
: "思考并执行完成。"),
title: reasoningTitle(summary, status),
summary,
status,
});
}
if (part.type === "tool-call") {
const input = stripStageNote(part.args);
const stageNote = getStageNote(part.args);
const argsText = formatToolArgs(input);
if (stageNote) {
items.push({
id: `${id}:stage`,
kind: "reasoning",
title: "阶段说明",
summary: stageNote,
status: getPartStatus(message, part),
});
}
items.push({
id,
kind: "tool",
title: part.toolName,
summary: summarizeTool(message, part),
status: getPartStatus(message, part),
argsText: part.argsText,
argsText,
result: decodeJsonString(part.result),
resultSummary: summarizeToolResult(decodeJsonString(part.result)),
rawResult:
@@ -525,7 +538,7 @@ function collectActivityItems(messages: readonly MessageState[]) {
: formatValue(decodeJsonString(part.result)),
fileLinks: extractLocalPaths(
[
part.argsText,
argsText,
part.result === undefined
? undefined
: formatValue(decodeJsonString(part.result)),
@@ -539,6 +552,42 @@ function collectActivityItems(messages: readonly MessageState[]) {
return items;
}
function getStageNote(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const note = (value as Record<string, unknown>).__claw_stage_note;
return typeof note === "string" ? note.trim() : "";
}
function stripStageNote(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const { __claw_stage_note: _stageNote, ...rest } = value as Record<
string,
unknown
>;
return rest;
}
function formatToolArgs(value: unknown) {
if (value === undefined) return undefined;
if (typeof value === "string") return value;
return JSON.stringify(value ?? {}, null, 2);
}
function reasoningTitle(
text: string,
status: ToolCallMessagePartStatus["type"],
) {
if (status === "running") return "思考中";
const normalized = text.trim();
if (
normalized.startsWith("思考中") ||
normalized.startsWith("思考并执行完成")
) {
return "思考完成";
}
return "阶段说明";
}
function ActivityFileLinks({ paths }: { paths: string[] }) {
return (
<div className="rounded-md border bg-muted/30 px-3 py-2">
@@ -337,6 +337,9 @@ export function toReplayRepository(
): ExportedMessageRepository {
const messages: ExportedMessageRepository["messages"] = [];
const toolResults = collectToolResults(session.messages ?? []);
const lastAssistantContentIndex = findLastAssistantContentIndex(
session.messages ?? [],
);
let previousId: string | null = null;
for (const [index, message] of (session.messages ?? []).entries()) {
if (message.role === "tool") continue;
@@ -347,26 +350,17 @@ export function toReplayRepository(
const id = `${fallbackSessionId}-${index}`;
const toolParts =
message.role === "assistant"
? toToolParts(message.tool_calls ?? [], toolResults)
: [];
const elapsedMs =
typeof message.metadata?.elapsed_ms === "number"
? message.metadata.elapsed_ms
: undefined;
const reasoningParts =
message.role === "assistant" &&
(toolParts.length > 0 || elapsedMs !== undefined)
? [
{
type: "reasoning",
text: formatHistoricalReasoning(toolParts.length, elapsedMs),
},
]
? toToolParts(message.tool_calls ?? [], toolResults, content)
: [];
const shouldShowAssistantText =
message.role !== "assistant" ||
!toolParts.length ||
index === lastAssistantContentIndex;
const parts = (
message.role === "assistant" &&
(reasoningParts.length || toolParts.length)
? [...reasoningParts, ...toolParts, { type: "text", text: content }]
message.role === "assistant" && toolParts.length
? shouldShowAssistantText
? [...toolParts, { type: "text", text: content }]
: toolParts
: [{ type: "text", text: content }]
) as UIMessage["parts"];
const uiMessage: UIMessage = {
@@ -405,6 +399,17 @@ export function toReplayRepository(
};
}
function findLastAssistantContentIndex(messages: readonly ClawStoredMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role !== "assistant") continue;
const content = cleanStoredContent(message.content ?? "");
if (!content) continue;
return index;
}
return -1;
}
function cleanStoredContent(content: string) {
const markers = ["\n\n[当前会话目录]", "\n[当前会话目录]"];
for (const marker of markers) {
@@ -426,12 +431,16 @@ function collectToolResults(messages: readonly ClawStoredMessage[]) {
function toToolParts(
toolCalls: readonly ClawStoredToolCall[],
toolResults: Map<string, string>,
stageNote?: string,
) {
return toolCalls.flatMap((call) => {
const toolCallId = call.id;
const toolName = call.function?.name ?? call.name;
if (!toolCallId || !toolName) return [];
const input = parseToolInput(call.function?.arguments ?? call.arguments);
const input = attachStageNote(
parseToolInput(call.function?.arguments ?? call.arguments),
stageNote,
);
const output = toolResults.get(toolCallId);
return [
{
@@ -446,16 +455,13 @@ function toToolParts(
});
}
function formatHistoricalReasoning(toolCount: number, elapsedMs?: number) {
const duration =
elapsedMs === undefined ? "" : `,用时 ${formatDuration(elapsedMs)}`;
if (toolCount <= 0) return `历史记录:思考完成${duration}`;
return `历史记录:本轮调用了 ${toolCount} 个工具${duration}`;
}
function formatDuration(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
function attachStageNote(input: unknown, stageNote?: string) {
const note = stageNote?.trim();
if (!note) return input;
if (input && typeof input === "object" && !Array.isArray(input)) {
return { ...input, __claw_stage_note: note };
}
return { value: input, __claw_stage_note: note };
}
function toThreadMessageContent(parts: UIMessage["parts"]) {
+113 -33
View File
@@ -27,7 +27,6 @@ import {
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
SlashIcon,
SquareIcon,
WrenchIcon,
} from "lucide-react";
@@ -69,6 +68,13 @@ type SkillSummary = {
when_to_use?: string;
};
type SlashCommandSummary = {
names: string[];
primary: string;
description?: string;
webui_supported?: boolean;
};
type ClawState = {
model?: string;
active_session_id?: string | null;
@@ -699,46 +705,19 @@ function modelProvider(model: string) {
const ComposerAssistButtons: FC = () => {
return (
<div className="flex items-center gap-1">
<PromptInsertDialog
title="快捷入口"
description="选择一个数据开发入口,会把可编辑提示词插入输入框。"
triggerIcon={<SlashIcon className="size-4" />}
triggerLabel="/"
items={QUICK_PROMPTS}
/>
<SlashCommandDialog />
<SkillInsertDialog />
<PromptInsertDialog
title="常用工具"
description="这里用于调试和明确意图,最终是否调用仍由 Agent 决策。"
triggerIcon={<WrenchIcon className="size-4" />}
triggerLabel="Tools"
triggerLabel=""
items={TOOL_PROMPTS}
/>
</div>
);
};
const QUICK_PROMPTS = [
{
title: "产品定义生成数据",
description: "从产品定义、例子 query 或手写边界整理数据目标。",
prompt:
"请根据我提供的产品定义或手写规则,先总结 query 语义边界、目标标签和排除范围,给我 review 数据生成目标,确认后再生成 canonical records。",
},
{
title: "线上数据挖掘",
description: "构造关键词/正则/domain 条件,采样候选并转换线上样本。",
prompt:
"请进入线上数据挖掘流程:先理解我的挖掘需求,设计筛选策略并采样候选,待我 review 后,把选中的线上数据直接转换为 canonical records,不要走生成数据分支。",
},
{
title: "校验 records",
description: "检查 canonical records 的字段、标签和多轮上下文。",
prompt:
"请校验我提供的 canonical records,重点检查 query、target、prev_session、timestamp 和标签格式,并输出需要修复的问题。",
},
];
const TOOL_PROMPTS = [
{
title: "data_agent_profile_router_sessions",
@@ -792,7 +771,9 @@ function PromptInsertDialog({
aria-label={title}
>
{triggerIcon}
<span className="hidden text-xs md:inline">{triggerLabel}</span>
{triggerLabel ? (
<span className="hidden text-xs md:inline">{triggerLabel}</span>
) : null}
</Button>
</DialogTrigger>
<DialogContent>
@@ -823,6 +804,100 @@ function PromptInsertDialog({
);
}
function SlashCommandDialog() {
const [open, setOpen] = useState(false);
const [commands, setCommands] = useState<SlashCommandSummary[]>([]);
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 (
<Dialog
open={open}
onOpenChange={(open) => {
setOpen(open);
if (open) loadCommands();
}}
>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 rounded-full px-2 font-mono text-muted-foreground text-sm"
aria-label="Slash Commands"
>
/
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
`/` WebUI
</DialogDescription>
</DialogHeader>
{status ? (
<p className="text-muted-foreground text-sm">{status}</p>
) : null}
<div className="grid max-h-96 gap-2 overflow-y-auto">
{commands.map((command) => {
const commandText = `/${command.primary}`;
const aliases = command.names
.filter((name) => name !== command.primary)
.map((name) => `/${name}`)
.join(" ");
return (
<button
key={command.primary}
type="button"
className="rounded-lg border px-3 py-2 text-left transition-colors hover:bg-muted"
onClick={() => {
insertComposerText(`${commandText}\n`);
setOpen(false);
}}
>
<div className="font-medium font-mono text-sm">
{commandText}
</div>
<div className="mt-1 line-clamp-2 text-muted-foreground text-xs">
{command.description || "暂无说明"}
</div>
{aliases ? (
<div className="mt-1 text-muted-foreground/80 text-[11px]">
{aliases}
</div>
) : null}
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
}
function SkillInsertDialog() {
const [open, setOpen] = useState(false);
const [skills, setSkills] = useState<SkillSummary[]>([]);
@@ -864,7 +939,6 @@ function SkillInsertDialog() {
aria-label="Skills"
>
<BookOpenIcon className="size-4" />
<span className="hidden text-xs md:inline">Skills</span>
</Button>
</DialogTrigger>
<DialogContent>
@@ -1032,10 +1106,16 @@ function summarizeActivityChainLabel(
return runningToolCount > 0 ? "调用工具中" : "思考中";
}
const label = lastReasoningLine || "思考完成";
const label = compactActivityLabel(lastReasoningLine || "思考完成");
return toolCount > 0 ? `${label} · 调用了 ${toolCount} 个工具` : label;
}
function compactActivityLabel(label: string) {
const normalized = label.replace(/\s+/g, " ").trim();
if (normalized.length <= 36) return normalized;
return `${normalized.slice(0, 35)}`;
}
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root