feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot
This commit is contained in:
1 parent
61562e947d
commit
98796a82c6
158 files changed
+8459
-2531
No files matched your search
@@ -0,0 +1,396 @@
|
||||
import type {
|
||||
BusEvent,
|
||||
ConversationInfo,
|
||||
PendingQuestion,
|
||||
ToolSnapshot,
|
||||
TurnUsage,
|
||||
} from "$lib/api/types";
|
||||
|
||||
export type ToolStatus = "running" | "done" | "error" | "aborted";
|
||||
export type TurnStatus = "running" | "done" | "interrupted" | "error";
|
||||
|
||||
export interface ToolNode {
|
||||
children: string[];
|
||||
endedAt: string | null;
|
||||
id: string;
|
||||
input: unknown;
|
||||
name: string;
|
||||
parent: string | null;
|
||||
result: string;
|
||||
startedAt: string;
|
||||
status: ToolStatus;
|
||||
}
|
||||
|
||||
export interface Turn {
|
||||
endedAt: string | null;
|
||||
id: string;
|
||||
itemOrigin: string | null;
|
||||
nodes: Record<string, ToolNode>;
|
||||
origin: string;
|
||||
resultSubtype: string | null;
|
||||
roots: string[];
|
||||
says: string[];
|
||||
startedAt: string;
|
||||
status: TurnStatus;
|
||||
text: string;
|
||||
thinking: number;
|
||||
usage: TurnUsage | null;
|
||||
userText: string | null;
|
||||
}
|
||||
|
||||
interface Cursor {
|
||||
event: BusEvent;
|
||||
parent: string | null;
|
||||
ts: string;
|
||||
turnId: string | null;
|
||||
}
|
||||
|
||||
const MAX_TURNS = 30;
|
||||
|
||||
function str(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function stopStatus(turn: Turn, stop: string | null): TurnStatus {
|
||||
if (stop === "end_turn") {
|
||||
return turn.status === "error" ? "error" : "done";
|
||||
}
|
||||
return stop === "interrupted" ? "interrupted" : "error";
|
||||
}
|
||||
|
||||
// Bus events in, turns → tool calls → subagent tool calls out. Reactive
|
||||
// through $state so the tree re-renders per node, not per event.
|
||||
export class ActivityModel {
|
||||
conversation = $state<ConversationInfo | null>(null);
|
||||
turns = $state<Turn[]>([]);
|
||||
question = $state<PendingQuestion | null>(null);
|
||||
|
||||
private readonly handlers: Record<string, (c: Cursor) => void> = {
|
||||
"conversation.created": (c) => this.onConversation(c),
|
||||
"conversation.updated": (c) => this.onConversation(c),
|
||||
question: (c) => this.onQuestion(c),
|
||||
"question.answered": () => {
|
||||
this.question = null;
|
||||
},
|
||||
"question.timeout": () => {
|
||||
this.question = null;
|
||||
},
|
||||
reply: (c) => this.onReply(c),
|
||||
result: (c) => this.onResult(c),
|
||||
say: (c) => this.onSay(c),
|
||||
stream: (c) => this.onStream(c),
|
||||
tool: (c) => this.onTool(c),
|
||||
"tool.result": (c) => this.onToolResult(c),
|
||||
"turn.end": (c) => this.onTurnEnd(c),
|
||||
"turn.start": (c) => this.onTurnStart(c),
|
||||
};
|
||||
|
||||
get running(): Turn | null {
|
||||
return this.turns.find((turn) => turn.status === "running") ?? null;
|
||||
}
|
||||
|
||||
setConversation(info: ConversationInfo): void {
|
||||
this.conversation = info;
|
||||
this.question = info.question;
|
||||
const now = new Date().toISOString();
|
||||
for (const turn of this.turns) {
|
||||
if (turn.status === "running" && turn.id !== info.running_turn) {
|
||||
this.closeTurn(turn, "interrupted", now);
|
||||
}
|
||||
}
|
||||
if (info.turn) {
|
||||
const turn = this.ensureTurn(
|
||||
info.turn.id,
|
||||
info.turn.started_at ?? now,
|
||||
info.turn.origin
|
||||
);
|
||||
turn.userText = info.turn.text ?? turn.userText;
|
||||
for (const tool of info.turn.tools) {
|
||||
this.applySnapshot(turn, tool);
|
||||
}
|
||||
} else if (info.running_turn) {
|
||||
this.ensureTurn(info.running_turn, now, null);
|
||||
}
|
||||
}
|
||||
|
||||
apply(event: BusEvent): void {
|
||||
const handler = this.handlers[event.type];
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
handler({
|
||||
event,
|
||||
parent: str(event.parent_tool_use_id),
|
||||
ts: event.ts ?? new Date().toISOString(),
|
||||
turnId: str(event.turn_id),
|
||||
});
|
||||
}
|
||||
|
||||
private applySnapshot(turn: Turn, tool: ToolSnapshot): void {
|
||||
const node = this.ensureNode(
|
||||
turn,
|
||||
tool.tool_use_id,
|
||||
tool.name,
|
||||
tool.parent_tool_use_id,
|
||||
tool.started_at
|
||||
);
|
||||
node.input = tool.input;
|
||||
if (tool.ended_at) {
|
||||
node.status = tool.is_error ? "error" : "done";
|
||||
node.endedAt = tool.ended_at;
|
||||
node.result = tool.content ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
private onTurnStart({ event, ts, turnId }: Cursor): void {
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, str(event.origin));
|
||||
turn.status = "running";
|
||||
turn.startedAt = ts;
|
||||
turn.itemOrigin = str(event.item_origin);
|
||||
turn.userText = str(event.text) ?? turn.userText;
|
||||
}
|
||||
|
||||
private onStream({ event, ts, turnId, parent }: Cursor): void {
|
||||
const raw = event.event;
|
||||
if (!(turnId && raw && typeof raw === "object")) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const sdk = raw as Record<string, unknown>;
|
||||
if (sdk.type === "content_block_start") {
|
||||
const block = sdk.content_block as Record<string, unknown> | undefined;
|
||||
if (block?.type === "tool_use" && typeof block.id === "string") {
|
||||
this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts);
|
||||
} else if (block?.type === "thinking" && parent === null) {
|
||||
turn.thinking += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (sdk.type === "content_block_delta" && parent === null) {
|
||||
const delta = sdk.delta as Record<string, unknown> | undefined;
|
||||
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
||||
turn.text += delta.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onTool({ event, ts, turnId, parent }: Cursor): void {
|
||||
if (!(turnId && typeof event.tool_use_id === "string")) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const node = this.ensureNode(
|
||||
turn,
|
||||
event.tool_use_id,
|
||||
str(event.name) ?? "?",
|
||||
parent,
|
||||
ts
|
||||
);
|
||||
node.input = event.input ?? null;
|
||||
}
|
||||
|
||||
private onToolResult({ event, ts, turnId, parent }: Cursor): void {
|
||||
if (!(turnId && typeof event.tool_use_id === "string")) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
|
||||
node.status = event.is_error ? "error" : "done";
|
||||
node.endedAt = ts;
|
||||
node.result = str(event.content) ?? "";
|
||||
}
|
||||
|
||||
private onResult({ event, ts, turnId }: Cursor): void {
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
turn.resultSubtype = str(event.subtype);
|
||||
if (event.is_error) {
|
||||
turn.status = "error";
|
||||
}
|
||||
}
|
||||
|
||||
private onTurnEnd({ event, ts, turnId }: Cursor): void {
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, str(event.origin));
|
||||
this.closeTurn(turn, stopStatus(turn, str(event.stop)), ts);
|
||||
turn.usage =
|
||||
event.usage && typeof event.usage === "object"
|
||||
? (event.usage as TurnUsage)
|
||||
: null;
|
||||
}
|
||||
|
||||
private onReply({ event, ts, turnId }: Cursor): void {
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
const turn = this.ensureTurn(turnId, ts, null);
|
||||
turn.userText = str(event.user_text);
|
||||
const text = str(event.text);
|
||||
if (text) {
|
||||
turn.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
private onSay({ event, ts, turnId }: Cursor): void {
|
||||
const text = str(event.text);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const turn = turnId ? this.ensureTurn(turnId, ts, null) : this.turns[0];
|
||||
turn?.says.push(text);
|
||||
}
|
||||
|
||||
private onQuestion({ event }: Cursor): void {
|
||||
if (typeof event.question_id === "string") {
|
||||
this.question = {
|
||||
id: event.question_id,
|
||||
questions: Array.isArray(event.questions) ? event.questions : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private onConversation({ event }: Cursor): void {
|
||||
if (!this.conversation || event.id !== this.conversation.id) {
|
||||
return;
|
||||
}
|
||||
this.conversation = {
|
||||
...this.conversation,
|
||||
...(event as unknown as Partial<ConversationInfo>),
|
||||
};
|
||||
}
|
||||
|
||||
private closeTurn(turn: Turn, status: TurnStatus, ts: string): void {
|
||||
turn.status = status;
|
||||
turn.endedAt = ts;
|
||||
for (const node of Object.values(turn.nodes)) {
|
||||
if (node.status === "running") {
|
||||
node.status = "aborted";
|
||||
node.endedAt = ts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureTurn(id: string, ts: string, origin: string | null): Turn {
|
||||
const existing = this.turns.find((t) => t.id === id);
|
||||
if (existing) {
|
||||
if (origin && existing.origin === "?") {
|
||||
existing.origin = origin;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const turn: Turn = {
|
||||
endedAt: null,
|
||||
id,
|
||||
itemOrigin: null,
|
||||
nodes: {},
|
||||
origin: origin ?? "?",
|
||||
resultSubtype: null,
|
||||
roots: [],
|
||||
says: [],
|
||||
startedAt: ts,
|
||||
status: "running",
|
||||
text: "",
|
||||
thinking: 0,
|
||||
usage: null,
|
||||
userText: null,
|
||||
};
|
||||
this.turns.unshift(turn);
|
||||
if (this.turns.length > MAX_TURNS) {
|
||||
this.turns.length = MAX_TURNS;
|
||||
}
|
||||
return this.turns[0];
|
||||
}
|
||||
|
||||
private ensureNode(
|
||||
turn: Turn,
|
||||
id: string,
|
||||
name: string,
|
||||
parent: string | null,
|
||||
ts: string
|
||||
): ToolNode {
|
||||
const existing = turn.nodes[id];
|
||||
if (existing) {
|
||||
if (existing.name === "?" && name !== "?") {
|
||||
existing.name = name;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
turn.nodes[id] = {
|
||||
children: [],
|
||||
endedAt: null,
|
||||
id,
|
||||
input: null,
|
||||
name,
|
||||
parent,
|
||||
result: "",
|
||||
startedAt: ts,
|
||||
status: "running",
|
||||
};
|
||||
if (parent) {
|
||||
this.ensureNode(turn, parent, "?", null, ts).children.push(id);
|
||||
} else {
|
||||
turn.roots.push(id);
|
||||
}
|
||||
return turn.nodes[id];
|
||||
}
|
||||
}
|
||||
|
||||
function pick(input: unknown, ...keys: string[]): string | null {
|
||||
if (!input || typeof input !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = input as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const WHITESPACE = /\s+/g;
|
||||
const KEYS_BY_TOOL: Record<string, string[]> = {
|
||||
Agent: ["description", "prompt"],
|
||||
Bash: ["command", "description"],
|
||||
Edit: ["file_path"],
|
||||
Glob: ["pattern"],
|
||||
Grep: ["pattern"],
|
||||
MultiEdit: ["file_path"],
|
||||
NotebookEdit: ["notebook_path"],
|
||||
Read: ["file_path", "notebook_path"],
|
||||
Skill: ["skill"],
|
||||
Task: ["description", "prompt"],
|
||||
WebFetch: ["url"],
|
||||
WebSearch: ["query"],
|
||||
Write: ["file_path"],
|
||||
};
|
||||
export const SUBAGENT_TOOLS = new Set(["Task", "Agent"]);
|
||||
|
||||
export function summarizeInput(name: string, input: unknown): string {
|
||||
if (input === null || input === undefined) {
|
||||
return "";
|
||||
}
|
||||
const keys =
|
||||
KEYS_BY_TOOL[name] ??
|
||||
(name.startsWith("mcp__gateway__") ? ["text", "title", "id"] : []);
|
||||
let summary = keys.length > 0 ? pick(input, ...keys) : null;
|
||||
if (summary === null && typeof input === "object") {
|
||||
const first = Object.values(input as Record<string, unknown>).find(
|
||||
(value) => typeof value === "string" && value.trim()
|
||||
);
|
||||
summary = typeof first === "string" ? first : JSON.stringify(input);
|
||||
}
|
||||
return (summary ?? String(input)).replace(WHITESPACE, " ").trim();
|
||||
}
|
||||
|
||||
export function toolLabel(name: string): string {
|
||||
return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name;
|
||||
}
|
||||
Reference in new issue
Block a user