feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import type { ActivityModel } from "./activity.svelte";
|
||||
import QuestionCard from "./question-card.svelte";
|
||||
import TurnCard from "./turn-card.svelte";
|
||||
|
||||
let {
|
||||
model,
|
||||
client,
|
||||
conversationId,
|
||||
connected,
|
||||
}: {
|
||||
model: ActivityModel;
|
||||
client: ApiClient;
|
||||
conversationId: string;
|
||||
connected: boolean;
|
||||
} = $props();
|
||||
|
||||
let now = $state(Date.now());
|
||||
const TICK_MS = 1000;
|
||||
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (model.running) {
|
||||
now = Date.now();
|
||||
}
|
||||
}, TICK_MS);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if model.question}
|
||||
<QuestionCard {client} {conversationId} question={model.question} />
|
||||
{/if}
|
||||
{#if model.turns.length === 0}
|
||||
<EmptyState
|
||||
hint={connected
|
||||
? "Live turns, tool calls and subagents land here the moment the agent moves; earlier turns are under History."
|
||||
: "Connecting to the event stream…"}
|
||||
title="Nothing live right now"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex flex-col">
|
||||
{#each model.turns as turn (turn.id)}
|
||||
<TurnCard {now} {turn} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { Binding } from "$lib/api/types";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
|
||||
let {
|
||||
client,
|
||||
conversationId,
|
||||
bindings,
|
||||
onChanged,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
conversationId: string;
|
||||
bindings: Binding[];
|
||||
onChanged: () => void;
|
||||
} = $props();
|
||||
|
||||
async function toggle(binding: Binding, visible: boolean) {
|
||||
try {
|
||||
await client.bind(
|
||||
conversationId,
|
||||
binding.frontend,
|
||||
binding.external_id,
|
||||
visible
|
||||
);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if bindings.length === 0}
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Not shown in any window. Bind it from a frontend, or it stays admin-only.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col">
|
||||
{#each bindings as binding (`${binding.frontend}:${binding.external_id}`)}
|
||||
<li
|
||||
class="flex items-center gap-2 border-b py-1.5 text-xs last:border-b-0"
|
||||
>
|
||||
<span class="font-medium">{binding.frontend}</span>
|
||||
<span
|
||||
class="truncate text-muted-foreground"
|
||||
title={binding.external_id}
|
||||
>
|
||||
{binding.external_id}
|
||||
</span>
|
||||
<Switch
|
||||
aria-label="Visible in {binding.frontend}"
|
||||
checked={binding.visible}
|
||||
class="ml-auto"
|
||||
onCheckedChange={(value) => toggle(binding, value)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import SendHorizontalIcon from "@lucide/svelte/icons/send-horizontal";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
|
||||
let {
|
||||
client,
|
||||
conversationId,
|
||||
disabled = false,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
conversationId: string;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
type Mode = "message" | "inject" | "urgent";
|
||||
const MODES: { value: Mode; label: string; hint: string }[] = [
|
||||
{
|
||||
hint: "as the user, mirrored to Telegram",
|
||||
label: "Message",
|
||||
value: "message",
|
||||
},
|
||||
{
|
||||
hint: "system note, rides with the next turn",
|
||||
label: "Inject",
|
||||
value: "inject",
|
||||
},
|
||||
{
|
||||
hint: "interrupts the running turn",
|
||||
label: "Urgent inject",
|
||||
value: "urgent",
|
||||
},
|
||||
];
|
||||
|
||||
let mode = $state<Mode>("message");
|
||||
let text = $state("");
|
||||
let busy = $state(false);
|
||||
const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]);
|
||||
|
||||
async function send() {
|
||||
const body = text.trim();
|
||||
if (!body || busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
if (mode === "message") {
|
||||
await client.postMessage(conversationId, body);
|
||||
} else {
|
||||
await client.inject(
|
||||
conversationId,
|
||||
body,
|
||||
mode === "urgent" ? "urgent" : "normal"
|
||||
);
|
||||
}
|
||||
text = "";
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="flex flex-col gap-2 border-t bg-background px-3 py-2"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
class="min-h-16 w-full resize-y rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
{disabled}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={mode === "message"
|
||||
? "Say something to the agent…"
|
||||
: "Text of the inject…"}
|
||||
rows="2"
|
||||
bind:value={text}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select.Root
|
||||
onValueChange={(value) => {
|
||||
mode = value as Mode;
|
||||
}}
|
||||
type="single"
|
||||
value={mode}
|
||||
>
|
||||
<Select.Trigger class="h-8 text-xs" size="sm"
|
||||
>{current.label}</Select.Trigger
|
||||
>
|
||||
<Select.Content>
|
||||
{#each MODES as option (option.value)}
|
||||
<Select.Item label={option.label} value={option.value}>
|
||||
<span class="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
<span class="text-muted-foreground text-xs">{option.hint}</span>
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<span class="hidden text-muted-foreground text-xs sm:inline"
|
||||
>{current.hint}</span
|
||||
>
|
||||
<Button
|
||||
class="ml-auto"
|
||||
disabled={disabled || busy || !text.trim()}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizontalIcon class="size-4" />
|
||||
Send
|
||||
<kbd class="hidden text-[10px] opacity-70 sm:inline">⌘↩</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
|
||||
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { LiveState } from "$lib/api/live.svelte";
|
||||
import type { ConversationInfo } from "$lib/api/types";
|
||||
import KindBadge from "$lib/components/kind-badge.svelte";
|
||||
import LiveDot from "$lib/components/live-dot.svelte";
|
||||
import StatusPill from "$lib/components/status-pill.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { fmtRelative, shortId } from "$lib/format";
|
||||
|
||||
let {
|
||||
client,
|
||||
info,
|
||||
liveState,
|
||||
liveDetail = null,
|
||||
href,
|
||||
onChanged,
|
||||
onOpen,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
info: ConversationInfo;
|
||||
liveState: LiveState;
|
||||
liveDetail?: string | null;
|
||||
href: (id: string) => string;
|
||||
onChanged: () => void;
|
||||
onOpen: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let branchOpen = $state(false);
|
||||
let renameOpen = $state(false);
|
||||
let busy = $state(false);
|
||||
let seed = $state("clean");
|
||||
let branchTitle = $state("");
|
||||
let branchText = $state("");
|
||||
let newTitle = $state("");
|
||||
|
||||
const SEEDS = [
|
||||
{ label: "clean - empty context", value: "clean" },
|
||||
{ label: "copy - copy of this history", value: "copy" },
|
||||
{ label: "brief - your text as the seed", value: "brief" },
|
||||
{ label: "morning - the handout", value: "morning" },
|
||||
];
|
||||
|
||||
async function run(action: () => Promise<unknown>, done: string) {
|
||||
busy = true;
|
||||
try {
|
||||
await action();
|
||||
toast.success(done);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function branch() {
|
||||
busy = true;
|
||||
try {
|
||||
const child = await client.branch(info.id, {
|
||||
seed,
|
||||
text: branchText || undefined,
|
||||
title: branchTitle || undefined,
|
||||
});
|
||||
branchOpen = false;
|
||||
toast.success("Branch opened");
|
||||
onOpen(child.id);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyId() {
|
||||
navigator.clipboard
|
||||
.writeText(info.id)
|
||||
.then(() => toast.success("Id copied"))
|
||||
.catch(() => toast.error("Clipboard is not available"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="flex flex-col gap-1.5 border-b px-4 py-2.5 sm:px-6">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<KindBadge kind={info.kind} />
|
||||
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
|
||||
{info.title || `${info.kind} ${shortId(info.id)}`}
|
||||
</h1>
|
||||
<StatusPill status={info.running_turn ? "running" : info.status} />
|
||||
{#if info.pending_question}
|
||||
<span class="font-medium text-link text-xs">question pending</span>
|
||||
{/if}
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<LiveDot detail={liveDetail} state={liveState} />
|
||||
<Button
|
||||
disabled={busy || info.status !== "open"}
|
||||
onclick={() => {
|
||||
branchOpen = true;
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<GitBranchIcon class="size-4" />
|
||||
Branch
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
aria-label="More actions"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={copyId}>
|
||||
<CopyIcon class="size-4" />
|
||||
Copy id
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
newTitle = info.title ?? "";
|
||||
renameOpen = true;
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#if info.parent && info.status === "open"}
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
run(() => client.merge(info.id), "Merged into the parent")}
|
||||
>
|
||||
Merge into parent
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if info.status === "open"}
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
run(() => client.update(info.id, { status: "closed" }), "Closed")}
|
||||
>
|
||||
Close
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
run(
|
||||
() => client.update(info.id, { status: "archived" }),
|
||||
"Archived"
|
||||
)}
|
||||
>
|
||||
Archive
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
run(() => client.update(info.id, { status: "open" }), "Reopened")}
|
||||
>
|
||||
Reopen
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
|
||||
>
|
||||
<span>{info.agent}</span>
|
||||
<span>via {info.origin}</span>
|
||||
{#if info.parent}
|
||||
<a class="text-link hover:underline" href={href(info.parent)}>
|
||||
parent {shortId(info.parent)}
|
||||
</a>
|
||||
{/if}
|
||||
<span class="tabular" title={info.id}>{shortId(info.id)}</span>
|
||||
<span class="tabular">
|
||||
{info.live ? "session live" : "no live session"}
|
||||
{info.busy ? " · busy" : ""}
|
||||
</span>
|
||||
<span class="tabular">
|
||||
last activity {fmtRelative(info.last_activity_at)}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Dialog.Root bind:open={branchOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Branch off this conversation</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
A branch gets its own window and session and ends with a merge back into
|
||||
this thread.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>Seed</Label>
|
||||
<Select.Root
|
||||
onValueChange={(value) => {
|
||||
seed = value;
|
||||
}}
|
||||
type="single"
|
||||
value={seed}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{SEEDS.find((s) => s.value === seed)?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each SEEDS as option (option.value)}
|
||||
<Select.Item label={option.label} value={option.value} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="branch-title">Title</Label>
|
||||
<Input
|
||||
id="branch-title"
|
||||
placeholder="optional"
|
||||
bind:value={branchTitle}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="branch-text">First message</Label>
|
||||
<textarea
|
||||
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
id="branch-text"
|
||||
placeholder={seed === "brief" ? "required for a brief seed" : "optional"}
|
||||
bind:value={branchText}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
onclick={() => {
|
||||
branchOpen = false;
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || (seed === "brief" && !branchText.trim())}
|
||||
onclick={branch}
|
||||
>
|
||||
Open branch
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root bind:open={renameOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Rename</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<Input placeholder="Title" bind:value={newTitle} />
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
onclick={() => {
|
||||
renameOpen = false;
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onclick={() =>
|
||||
run(async () => {
|
||||
await client.update(info.id, { title: newTitle });
|
||||
renameOpen = false;
|
||||
}, "Renamed")}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import ActivityFeed from "./activity-feed.svelte";
|
||||
import BindingsList from "./bindings-list.svelte";
|
||||
import Composer from "./composer.svelte";
|
||||
import { ConversationFeed } from "./conversation.svelte";
|
||||
import ConversationHeader from "./conversation-header.svelte";
|
||||
import HistoryView from "./history-view.svelte";
|
||||
import QueueList from "./queue-list.svelte";
|
||||
import RawEntries from "./raw-entries.svelte";
|
||||
|
||||
let {
|
||||
client,
|
||||
id,
|
||||
href,
|
||||
onOpen,
|
||||
compact = false,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
id: string;
|
||||
href: (id: string) => string;
|
||||
onOpen: (id: string) => void;
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
|
||||
const feed = new ConversationFeed(
|
||||
() => client,
|
||||
untrack(() => id)
|
||||
);
|
||||
let tab = $state("activity");
|
||||
let historyKey = $state(0);
|
||||
let landed = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
feed.start();
|
||||
return () => feed.stop();
|
||||
});
|
||||
|
||||
const info = $derived(feed.model.conversation);
|
||||
|
||||
function refresh() {
|
||||
feed.refresh().catch(() => undefined);
|
||||
historyKey += 1;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const { running } = feed.model;
|
||||
if (!running) {
|
||||
untrack(() => {
|
||||
historyKey += 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (feed.loaded && !landed) {
|
||||
landed = true;
|
||||
tab = feed.model.running ? "activity" : "history";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
{#if feed.error && !info}
|
||||
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
|
||||
{:else if !info}
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<Skeleton class="h-8 w-1/2" />
|
||||
<Skeleton class="h-24 w-full" />
|
||||
</div>
|
||||
{:else}
|
||||
<ConversationHeader
|
||||
{client}
|
||||
{href}
|
||||
{info}
|
||||
liveDetail={feed.live.detail}
|
||||
liveState={feed.live.state}
|
||||
onChanged={refresh}
|
||||
{onOpen}
|
||||
/>
|
||||
<div
|
||||
class="grid min-h-0 flex-1 grid-cols-1 {compact
|
||||
? ''
|
||||
: 'lg:grid-cols-[minmax(0,1fr)_18rem]'}"
|
||||
>
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
|
||||
<Tabs.List class="mx-4 mt-2 w-fit sm:mx-6">
|
||||
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
||||
<Tabs.Trigger value="history">History</Tabs.Trigger>
|
||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
||||
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta"
|
||||
>Meta</Tabs.Trigger
|
||||
>
|
||||
</Tabs.List>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6">
|
||||
<Tabs.Content value="activity">
|
||||
<ActivityFeed
|
||||
{client}
|
||||
connected={feed.live.state === "open"}
|
||||
conversationId={id}
|
||||
model={feed.model}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="history">
|
||||
<HistoryView
|
||||
{client}
|
||||
conversationId={id}
|
||||
refreshKey={historyKey}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="raw">
|
||||
<RawEntries {client} conversationId={id} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="meta">{@render rail()}</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
<Composer
|
||||
{client}
|
||||
conversationId={id}
|
||||
disabled={info.status !== "open"}
|
||||
/>
|
||||
</div>
|
||||
{#if !compact}
|
||||
<aside
|
||||
class="hidden min-h-0 overflow-y-auto border-l bg-sidebar/50 p-4 lg:block"
|
||||
>
|
||||
{@render rail()}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet rail()}
|
||||
{#if info}
|
||||
<div class="flex flex-col gap-5">
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Queue
|
||||
</h2>
|
||||
<QueueList items={info.queue} />
|
||||
</section>
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Windows
|
||||
</h2>
|
||||
<BindingsList
|
||||
bindings={info.bindings}
|
||||
{client}
|
||||
conversationId={id}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
</section>
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Flags
|
||||
</h2>
|
||||
{#if Object.keys(info.flags).length === 0}
|
||||
<p class="text-muted-foreground text-xs">No flags set.</p>
|
||||
{:else}
|
||||
<dl
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs"
|
||||
>
|
||||
{#each Object.entries(info.flags) as [key, value] (key)}
|
||||
<dt class="text-muted-foreground">{key}</dt>
|
||||
<dd class="truncate">{JSON.stringify(value)}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
</section>
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Session
|
||||
</h2>
|
||||
<p class="tabular break-all text-muted-foreground text-xs">
|
||||
{info.session_id ?? "no session yet"}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import { LiveStream } from "$lib/api/live.svelte";
|
||||
import { ActivityModel } from "./activity.svelte";
|
||||
|
||||
// One conversation, live: the snapshot from ``GET /api/conversations/{id}``
|
||||
// (bindings, queue, the turn in flight) plus its SSE stream folded into an
|
||||
// ``ActivityModel``. Shared by the admin thread page and the Obsidian panel.
|
||||
export class ConversationFeed {
|
||||
model = new ActivityModel();
|
||||
error = $state<string | null>(null);
|
||||
loaded = $state(false);
|
||||
readonly id: string;
|
||||
readonly live: LiveStream;
|
||||
private readonly client: () => ApiClient | null;
|
||||
|
||||
constructor(client: () => ApiClient | null, id: string) {
|
||||
this.client = client;
|
||||
this.id = id;
|
||||
this.live = new LiveStream(client, `/api/conversations/${id}/events`, {
|
||||
onEvent: (event) => this.model.apply(event),
|
||||
prepare: () => this.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const client = this.client();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.model.setConversation(await client.conversation(this.id));
|
||||
this.error = null;
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.live.start();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.live.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { clip } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import { summarizeInput, toolLabel } from "./activity.svelte";
|
||||
|
||||
let {
|
||||
client,
|
||||
conversationId,
|
||||
refreshKey = 0,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
conversationId: string;
|
||||
refreshKey?: number;
|
||||
} = $props();
|
||||
|
||||
let messages = $state<HistoryMessage[] | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let showResults = $state(false);
|
||||
|
||||
async function load() {
|
||||
failure = null;
|
||||
try {
|
||||
({ messages } = await client.history(conversationId));
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (refreshKey >= 0) {
|
||||
load();
|
||||
}
|
||||
});
|
||||
|
||||
function blocks(message: HistoryMessage): ContentBlock[] {
|
||||
return typeof message.content === "string"
|
||||
? [{ text: message.content, type: "text" }]
|
||||
: message.content;
|
||||
}
|
||||
|
||||
function resultText(block: ContentBlock): string {
|
||||
const { content } = block;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) =>
|
||||
part && typeof part === "object" && "text" in part
|
||||
? String((part as { text: unknown }).text)
|
||||
: ""
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const RESULT_CLIP = 600;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="flex items-center gap-2 self-end text-muted-foreground text-xs">
|
||||
<Switch aria-label="Show tool results" bind:checked={showResults} />
|
||||
show tool results
|
||||
</span>
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if messages === null}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Skeleton class="h-10 w-2/3" />
|
||||
<Skeleton class="h-16 w-full" />
|
||||
<Skeleton class="h-10 w-1/2" />
|
||||
</div>
|
||||
{:else if messages.length === 0}
|
||||
<EmptyState
|
||||
hint="The transcript mirror is empty - nothing has been said in this conversation yet."
|
||||
title="No history"
|
||||
/>
|
||||
{:else}
|
||||
{#each messages as message, index (index)}
|
||||
{@const parts = blocks(message)}
|
||||
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
|
||||
{#if !(isResultOnly && !showResults)}
|
||||
<div
|
||||
class={cn(
|
||||
"flex flex-col gap-1.5",
|
||||
message.role === "user" && !isResultOnly && "items-end"
|
||||
)}
|
||||
>
|
||||
{#each parts as block, blockIndex (blockIndex)}
|
||||
{#if block.type === "text" && block.text}
|
||||
<p
|
||||
class={cn(
|
||||
"max-w-[75ch] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary/10"
|
||||
: "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
</p>
|
||||
{:else if block.type === "tool_use"}
|
||||
<p class="flex items-baseline gap-2 px-1 text-xs">
|
||||
<span class="font-medium">{toolLabel(block.name ?? "?")}</span>
|
||||
<span class="truncate text-muted-foreground">
|
||||
{clip(summarizeInput(block.name ?? "", block.input), 160)}
|
||||
</span>
|
||||
</p>
|
||||
{:else if block.type === "tool_result" && showResults}
|
||||
<pre
|
||||
class={cn(
|
||||
"max-h-48 max-w-full overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
|
||||
block.is_error ? "bg-destructive/8 text-destructive" : "bg-muted/40"
|
||||
)}
|
||||
>{clip(resultText(block), RESULT_CLIP)}</pre>
|
||||
{:else if block.type === "thinking"}
|
||||
<p class="px-1 text-kind-deep text-xs">thinking</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { PendingQuestion } from "$lib/api/types";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
|
||||
let {
|
||||
client,
|
||||
conversationId,
|
||||
question,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
conversationId: string;
|
||||
question: PendingQuestion;
|
||||
} = $props();
|
||||
|
||||
let free = $state("");
|
||||
let busy = $state(false);
|
||||
|
||||
async function answer(text: string) {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await client.answer(conversationId, question.id, text);
|
||||
free = "";
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="flex flex-col gap-3 rounded-lg border border-primary/40 bg-primary/5 p-3"
|
||||
>
|
||||
<p class="font-medium text-link text-xs uppercase tracking-wide">
|
||||
The agent is asking
|
||||
</p>
|
||||
{#each question.questions as q, index (index)}
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm">{q.question}</p>
|
||||
{#if q.options?.length}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each q.options as option (option.label)}
|
||||
<Button
|
||||
disabled={busy}
|
||||
onclick={() => answer(option.label)}
|
||||
size="sm"
|
||||
title={option.description}
|
||||
variant="outline"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<form
|
||||
class="flex gap-2"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
answer(free);
|
||||
}}
|
||||
>
|
||||
<Input placeholder="Answer in your own words" bind:value={free} />
|
||||
<Button disabled={busy || !free.trim()} size="sm" type="submit"
|
||||
>Send</Button
|
||||
>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { QueueItem } from "$lib/api/types";
|
||||
import StatusPill from "$lib/components/status-pill.svelte";
|
||||
import { clip, fmtRelative } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let { items }: { items: QueueItem[] } = $props();
|
||||
|
||||
const PRIORITY: Record<string, string> = {
|
||||
normal: "text-note",
|
||||
urgent: "text-destructive",
|
||||
user: "text-foreground",
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="text-muted-foreground text-xs">Queue is empty.</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col">
|
||||
{#each items as item (item.id)}
|
||||
<li class="flex flex-col gap-0.5 border-b py-1.5 text-xs last:border-b-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={cn("font-medium", PRIORITY[item.priority])}>
|
||||
{item.priority}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{item.origin}</span>
|
||||
<StatusPill class="ml-auto" status={item.status} />
|
||||
</div>
|
||||
<p class="text-foreground/90">{clip(item.text, 140)}</p>
|
||||
<span class="tabular text-muted-foreground">
|
||||
{fmtRelative(item.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { EntriesPage } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { fmtTime } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let {
|
||||
client,
|
||||
conversationId,
|
||||
}: { client: ApiClient; conversationId: string } = $props();
|
||||
|
||||
const PAGE = 50;
|
||||
let page = $state<EntriesPage | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let subpath = $state("");
|
||||
let offset = $state<number | null>(null);
|
||||
let open = $state<Set<number>>(new Set());
|
||||
|
||||
async function load(sub: string, from: number | null) {
|
||||
failure = null;
|
||||
try {
|
||||
page = await client.entries(conversationId, {
|
||||
limit: PAGE,
|
||||
offset: from ?? undefined,
|
||||
subpath: sub,
|
||||
});
|
||||
({ offset } = page);
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load(subpath, offset);
|
||||
});
|
||||
|
||||
function toggle(index: number) {
|
||||
const next = new Set(open);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
open = next;
|
||||
}
|
||||
|
||||
function headline(entry: Record<string, unknown>): string {
|
||||
const message = entry.message as Record<string, unknown> | undefined;
|
||||
const content = message?.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type === "text") {
|
||||
return String(b.text ?? "");
|
||||
}
|
||||
if (b.type === "tool_use") {
|
||||
return `tool_use ${String(b.name ?? "")}`;
|
||||
}
|
||||
if (b.type === "tool_result") {
|
||||
return "tool_result";
|
||||
}
|
||||
return String(b.type ?? "");
|
||||
})
|
||||
.join(" · ");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const HEADLINE_MAX = 140;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
{#if page && page.subpaths.length > 0}
|
||||
<Select.Root
|
||||
onValueChange={(value) => {
|
||||
subpath = value === "main" ? "" : value;
|
||||
offset = null;
|
||||
}}
|
||||
type="single"
|
||||
value={subpath || "main"}
|
||||
>
|
||||
<Select.Trigger class="h-7 text-xs" size="sm">
|
||||
{subpath || "main transcript"}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item label="main transcript" value="main" />
|
||||
{#each page.subpaths as sub (sub)}
|
||||
<Select.Item label={sub} value={sub} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
{#if page}
|
||||
<span class="tabular text-muted-foreground">
|
||||
{page.total === 0 ? "0" : `${page.offset + 1}–${page.offset + page.entries.length}`}
|
||||
of {page.total}
|
||||
</span>
|
||||
<div class="ml-auto flex gap-1">
|
||||
<Button
|
||||
disabled={page.offset === 0}
|
||||
onclick={() => {
|
||||
offset = Math.max(0, (page?.offset ?? 0) - PAGE);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Newer… older
|
||||
</Button>
|
||||
<Button
|
||||
disabled={page.offset + page.entries.length >= page.total}
|
||||
onclick={() => {
|
||||
offset = (page?.offset ?? 0) + PAGE;
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={() => load(subpath, offset)} />
|
||||
{:else if page === null}
|
||||
<Skeleton class="h-24 w-full" />
|
||||
{:else if page.entries.length === 0}
|
||||
<EmptyState
|
||||
hint="Entries appear once the session store has mirrored the first turn."
|
||||
title="No transcript entries"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex flex-col">
|
||||
{#each page.entries as entry, index (index)}
|
||||
{@const seq = page.offset + index}
|
||||
{@const isOpen = open.has(seq)}
|
||||
<div class="border-b">
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
class="row-hover ledger-grid w-full grid-cols-[auto_auto_auto_minmax(0,1fr)] px-1.5 py-1.5 text-left text-xs"
|
||||
onclick={() => toggle(seq)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
class={cn(
|
||||
"size-3.5 text-icon transition-transform duration-150",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span class="tabular w-8 text-muted-foreground">{seq}</span>
|
||||
<span class="w-16 font-medium">{String(entry.type ?? "?")}</span>
|
||||
<span class="truncate text-muted-foreground">
|
||||
{headline(entry).slice(0, HEADLINE_MAX)}
|
||||
<span class="tabular ml-2">
|
||||
{fmtTime(typeof entry.timestamp === "string" ? entry.timestamp : null)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if isOpen}
|
||||
<pre
|
||||
class="mb-2 ml-7 max-h-96 overflow-auto rounded-md bg-muted/50 p-2 text-xs whitespace-pre-wrap break-all"
|
||||
>{JSON.stringify(
|
||||
entry,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { elapsedMs, fmtDuration } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import {
|
||||
SUBAGENT_TOOLS,
|
||||
summarizeInput,
|
||||
type ToolNode,
|
||||
toolLabel,
|
||||
} from "./activity.svelte";
|
||||
import ToolNodeView from "./tool-node.svelte";
|
||||
|
||||
let {
|
||||
node,
|
||||
nodes,
|
||||
now,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: ToolNode;
|
||||
nodes: Record<string, ToolNode>;
|
||||
now: number;
|
||||
depth?: number;
|
||||
} = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
const duration = $derived(elapsedMs(node.startedAt, node.endedAt, now));
|
||||
const summary = $derived(summarizeInput(node.name, node.input));
|
||||
const subagent = $derived(SUBAGENT_TOOLS.has(node.name));
|
||||
const DOT: Record<string, string> = {
|
||||
aborted: "bg-muted-foreground/50",
|
||||
done: "bg-ok",
|
||||
error: "bg-destructive",
|
||||
running: "bg-signal animate-pulse-dot",
|
||||
};
|
||||
const SHOW_DURATION_AFTER_MS = 2000;
|
||||
</script>
|
||||
|
||||
<div class={cn("flex flex-col", depth > 0 && "ml-3 border-l pl-2")}>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
class="row-hover ledger-grid grid-cols-[auto_auto_minmax(0,1fr)_auto] rounded-md px-1.5 py-1 text-left text-sm"
|
||||
onclick={() => {
|
||||
expanded = !expanded;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
class={cn(
|
||||
"size-3.5 text-icon transition-transform duration-150",
|
||||
expanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span class={cn("size-2 rounded-full", DOT[node.status])}></span>
|
||||
<span class="flex min-w-0 items-baseline gap-2">
|
||||
<span class={cn("shrink-0 font-medium", subagent && "text-kind-deep")}>
|
||||
{toolLabel(node.name)}
|
||||
</span>
|
||||
{#if summary}
|
||||
<span class="truncate text-muted-foreground">{summary}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if node.status !== "running" || duration >= SHOW_DURATION_AFTER_MS}
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{fmtDuration(duration)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if expanded}
|
||||
<div class="ml-7 mb-1 flex flex-col gap-1.5">
|
||||
{#if node.input !== null && node.input !== undefined}
|
||||
<pre
|
||||
class="max-h-64 overflow-auto rounded-md bg-muted/60 p-2 text-xs whitespace-pre-wrap break-all"
|
||||
>{JSON.stringify(
|
||||
node.input,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
{/if}
|
||||
{#if node.result}
|
||||
<pre
|
||||
class={cn(
|
||||
"max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
|
||||
node.status === "error"
|
||||
? "bg-destructive/8 text-destructive"
|
||||
: "bg-muted/40"
|
||||
)}
|
||||
>{node.result}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if node.children.length > 0}
|
||||
<div class="flex flex-col">
|
||||
{#each node.children as childId (childId)}
|
||||
{#if nodes[childId]}
|
||||
<ToolNodeView depth={depth + 1} node={nodes[childId]} {nodes} {now} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
|
||||
import StatusPill from "$lib/components/status-pill.svelte";
|
||||
import {
|
||||
elapsedMs,
|
||||
fmtDuration,
|
||||
fmtMoney,
|
||||
fmtTime,
|
||||
fmtTokens,
|
||||
} from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { Turn } from "./activity.svelte";
|
||||
import ToolNodeView from "./tool-node.svelte";
|
||||
|
||||
let { turn, now }: { turn: Turn; now: number } = $props();
|
||||
|
||||
const duration = $derived(
|
||||
turn.usage?.duration_ms ?? elapsedMs(turn.startedAt, turn.endedAt, now)
|
||||
);
|
||||
const originLabel = $derived(
|
||||
turn.itemOrigin && turn.itemOrigin !== turn.origin
|
||||
? `${turn.origin} · ${turn.itemOrigin}`
|
||||
: turn.origin
|
||||
);
|
||||
const toolCount = $derived(Object.keys(turn.nodes).length);
|
||||
</script>
|
||||
|
||||
<article
|
||||
class={cn(
|
||||
"flex animate-rise flex-col gap-2 border-b py-3",
|
||||
turn.status === "running" && "bg-signal/[0.03]"
|
||||
)}
|
||||
>
|
||||
<header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs">
|
||||
<StatusPill status={turn.status} />
|
||||
<span
|
||||
class={cn(
|
||||
"font-medium",
|
||||
turn.origin === "inject" ? "text-note" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{originLabel}
|
||||
</span>
|
||||
<span class="tabular text-muted-foreground">{fmtTime(turn.startedAt)}</span>
|
||||
<span class="tabular text-muted-foreground">{fmtDuration(duration)}</span>
|
||||
{#if toolCount > 0}
|
||||
<span class="tabular text-muted-foreground">
|
||||
{toolCount}
|
||||
tool {toolCount === 1 ? "call" : "calls"}
|
||||
</span>
|
||||
{/if}
|
||||
{#if turn.thinking > 0 && turn.status === "running"}
|
||||
<span class="text-kind-deep">thinking…</span>
|
||||
{/if}
|
||||
{#if turn.usage}
|
||||
<span class="tabular ml-auto text-muted-foreground">
|
||||
in {fmtTokens(turn.usage.input)} · out {fmtTokens(turn.usage.output)} ·
|
||||
cache {fmtTokens(turn.usage.cache_read)}/{fmtTokens(
|
||||
turn.usage.cache_creation
|
||||
)}
|
||||
{#if typeof turn.usage.cost_usd === "number"}
|
||||
· {fmtMoney(turn.usage.cost_usd)}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</header>
|
||||
{#if turn.userText}
|
||||
<p
|
||||
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5 text-sm whitespace-pre-wrap"
|
||||
>
|
||||
{turn.userText}
|
||||
</p>
|
||||
{/if}
|
||||
{#if turn.roots.length > 0}
|
||||
<div class="flex flex-col">
|
||||
{#each turn.roots as rootId (rootId)}
|
||||
{#if turn.nodes[rootId]}
|
||||
<ToolNodeView node={turn.nodes[rootId]} nodes={turn.nodes} {now} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#each turn.says as text, index (index)}
|
||||
<p class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5 text-sm">
|
||||
<MessageSquareIcon class="mt-0.5 size-3.5 shrink-0 text-link" />
|
||||
<span class="whitespace-pre-wrap">{text}</span>
|
||||
</p>
|
||||
{/each}
|
||||
{#if turn.text}
|
||||
<p class="mx-1 px-1 text-sm whitespace-pre-wrap">{turn.text}</p>
|
||||
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
|
||||
<p class="mx-1 px-1 text-muted-foreground text-sm">
|
||||
Waiting for the model…
|
||||
</p>
|
||||
{/if}
|
||||
</article>
|
||||
Reference in New Issue
Block a user