Refine WebUI commands and data skills
This commit is contained in:
@@ -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"]) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user