feat(serve+dashboard): write endpoints, SSE live, run dialog
Serve API: - POST /api/threads — run a new thread - POST /api/threads/:id/kill — kill thread - POST /api/threads/:id/pause — pause thread - POST /api/threads/:id/resume — resume thread - GET /api/threads/:id/live — SSE stream of thread records Dashboard: - Run Thread dialog (select workflow, enter prompt, set maxRounds) - Thread detail controls (pause/resume/kill buttons) - postJson API helper for write operations 262 tests pass. Refs: #118
This commit is contained in:
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
|
|
||||||
import { createCasRoutes } from "./routes-cas.js";
|
import { createCasRoutes } from "./routes-cas.js";
|
||||||
|
import { createLiveRoutes } from "./routes-live.js";
|
||||||
import { createThreadRoutes } from "./routes-thread.js";
|
import { createThreadRoutes } from "./routes-thread.js";
|
||||||
import { createWorkflowRoutes } from "./routes-workflow.js";
|
import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export function createApp(storageRoot: string): Hono {
|
|||||||
|
|
||||||
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
|
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
|
||||||
app.route("/api/threads", createThreadRoutes(storageRoot));
|
app.route("/api/threads", createThreadRoutes(storageRoot));
|
||||||
|
app.route("/api/threads", createLiveRoutes(storageRoot));
|
||||||
app.route("/api/cas", createCasRoutes(storageRoot));
|
app.route("/api/cas", createCasRoutes(storageRoot));
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { watch } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { streamSSE } from "hono/streaming";
|
||||||
|
|
||||||
|
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||||
|
|
||||||
|
type PumpState = {
|
||||||
|
contentOffset: number;
|
||||||
|
carry: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseJsonLine(line: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { raw: line };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkflowResult(record: unknown): boolean {
|
||||||
|
return (
|
||||||
|
record !== null &&
|
||||||
|
typeof record === "object" &&
|
||||||
|
"type" in (record as Record<string, unknown>) &&
|
||||||
|
(record as Record<string, unknown>).type === "workflow-result"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNewLines(text: string, state: PumpState): string[] {
|
||||||
|
if (text.length < state.contentOffset) {
|
||||||
|
state.contentOffset = 0;
|
||||||
|
state.carry = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = text.slice(state.contentOffset);
|
||||||
|
state.contentOffset = text.length;
|
||||||
|
state.carry += chunk;
|
||||||
|
|
||||||
|
const parts = state.carry.split("\n");
|
||||||
|
state.carry = parts.pop() ?? "";
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const line of parts) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed !== "") {
|
||||||
|
lines.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLiveRoutes(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/:threadId/live", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||||
|
if (dataPath === null) {
|
||||||
|
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoPath = join(dirname(dataPath), `${threadId}.info.jsonl`);
|
||||||
|
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
const dataState: PumpState = { contentOffset: 0, carry: "" };
|
||||||
|
const infoState: PumpState = { contentOffset: 0, carry: "" };
|
||||||
|
let eventId = 0;
|
||||||
|
|
||||||
|
async function pumpData(): Promise<boolean> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(dataPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = parseNewLines(text, dataState);
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = parseJsonLine(line);
|
||||||
|
eventId++;
|
||||||
|
await stream.writeSSE({
|
||||||
|
event: "record",
|
||||||
|
data: JSON.stringify(record),
|
||||||
|
id: String(eventId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isWorkflowResult(record)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pumpInfo(): Promise<void> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(infoPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = parseNewLines(text, infoState);
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = parseJsonLine(line);
|
||||||
|
if (
|
||||||
|
typeof record === "object" &&
|
||||||
|
record !== null &&
|
||||||
|
"raw" in (record as Record<string, unknown>)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eventId++;
|
||||||
|
await stream.writeSSE({
|
||||||
|
event: "info",
|
||||||
|
data: JSON.stringify(record),
|
||||||
|
id: String(eventId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial pump
|
||||||
|
const done = await pumpData();
|
||||||
|
await pumpInfo();
|
||||||
|
if (done) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes
|
||||||
|
const controller = new AbortController();
|
||||||
|
let completed = false;
|
||||||
|
|
||||||
|
const dataWatcher = watch(dataPath, async () => {
|
||||||
|
if (completed) return;
|
||||||
|
const finished = await pumpData();
|
||||||
|
if (finished) {
|
||||||
|
completed = true;
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let infoWatcher: ReturnType<typeof watch> | null = null;
|
||||||
|
try {
|
||||||
|
infoWatcher = watch(infoPath, async () => {
|
||||||
|
if (completed) return;
|
||||||
|
await pumpInfo();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// info file may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.onAbort(() => {
|
||||||
|
completed = true;
|
||||||
|
dataWatcher.close();
|
||||||
|
infoWatcher?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep stream alive until completion or client disconnect
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (completed) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
|
stream.onAbort(() => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
dataWatcher.close();
|
||||||
|
infoWatcher?.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
listRunningThreads,
|
listRunningThreads,
|
||||||
resolveThreadDataPath,
|
resolveThreadDataPath,
|
||||||
} from "../../thread-scan.js";
|
} from "../../thread-scan.js";
|
||||||
|
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
|
||||||
|
import { cmdRun } from "../thread/run.js";
|
||||||
|
|
||||||
export function createThreadRoutes(storageRoot: string): Hono {
|
export function createThreadRoutes(storageRoot: string): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -42,5 +44,55 @@ export function createThreadRoutes(storageRoot: string): Hono {
|
|||||||
return c.json({ threadId, records });
|
return c.json({ threadId, records });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/", async (c) => {
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await c.req.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "invalid JSON body" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = body.workflow;
|
||||||
|
const prompt = body.prompt;
|
||||||
|
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
|
||||||
|
|
||||||
|
if (typeof name !== "string" || typeof prompt !== "string") {
|
||||||
|
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ threadId: result.value.threadId }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/:threadId/kill", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const result = await cmdKill(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/:threadId/pause", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const result = await cmdPause(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/:threadId/resume", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const result = await cmdResume(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
const BASE = "/api";
|
const BASE = "/api";
|
||||||
|
|
||||||
|
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText })) as { error: string };
|
||||||
|
throw new Error(err.error || `API ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(path: string): Promise<T> {
|
async function fetchJson<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${path}`);
|
const res = await fetch(`${BASE}${path}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -46,6 +59,22 @@ export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
|
|||||||
return fetchJson(`/threads/${id}`);
|
return fetchJson(`/threads/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function runThread(workflow: string, prompt: string, maxRounds: number = 10): Promise<{ threadId: string }> {
|
||||||
|
return postJson("/threads", { workflow, prompt, maxRounds });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killThread(threadId: string): Promise<{ ok: boolean }> {
|
||||||
|
return postJson(`/threads/${threadId}/kill`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
|
||||||
|
return postJson(`/threads/${threadId}/pause`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
|
||||||
|
return postJson(`/threads/${threadId}/resume`, {});
|
||||||
|
}
|
||||||
|
|
||||||
export function getHealth(): Promise<{ ok: boolean }> {
|
export function getHealth(): Promise<{ ok: boolean }> {
|
||||||
return fetchJson("/healthz");
|
return fetchJson("/healthz");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import { ThreadList } from "./components/thread-list.tsx";
|
|||||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||||
import { StatusBar } from "./components/status-bar.tsx";
|
import { StatusBar } from "./components/status-bar.tsx";
|
||||||
|
import { RunDialog } from "./components/run-dialog.tsx";
|
||||||
|
|
||||||
type View = "threads" | "workflows";
|
type View = "threads" | "workflows";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [view, setView] = useState<View>("threads");
|
const [view, setView] = useState<View>("threads");
|
||||||
const [selectedThread, setSelectedThread] = useState<string | null>(null);
|
const [selectedThread, setSelectedThread] = useState<string | null>(null);
|
||||||
|
const [showRun, setShowRun] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
<Sidebar view={view} onViewChange={setView} />
|
<Sidebar view={view} onViewChange={setView} />
|
||||||
<main className="flex-1 overflow-hidden flex flex-col">
|
<main className="flex-1 overflow-hidden flex flex-col">
|
||||||
<StatusBar />
|
<StatusBar onRun={() => setShowRun(true)} />
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
{view === "threads" && !selectedThread && (
|
{view === "threads" && !selectedThread && (
|
||||||
<ThreadList onSelect={setSelectedThread} />
|
<ThreadList onSelect={setSelectedThread} />
|
||||||
@@ -26,6 +28,16 @@ export function App() {
|
|||||||
{view === "workflows" && <WorkflowList />}
|
{view === "workflows" && <WorkflowList />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
{showRun && (
|
||||||
|
<RunDialog
|
||||||
|
onClose={() => setShowRun(false)}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setShowRun(false);
|
||||||
|
setView("threads");
|
||||||
|
setSelectedThread(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { listWorkflows, runThread } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (threadId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RunDialog({ onClose, onCreated }: Props) {
|
||||||
|
const workflows = useFetch(() => listWorkflows(), []);
|
||||||
|
const [workflow, setWorkflow] = useState("");
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [maxRounds, setMaxRounds] = useState(10);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!workflow || !prompt) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await runThread(workflow, prompt, maxRounds);
|
||||||
|
onCreated(result.threadId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
|
style={{ background: "rgba(0,0,0,0.6)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg p-6 rounded-lg border"
|
||||||
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Run Thread</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm block mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Workflow
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={workflow}
|
||||||
|
onChange={(e) => setWorkflow(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select a workflow...</option>
|
||||||
|
{workflows.status === "ok" &&
|
||||||
|
workflows.data.workflows.map((w) => (
|
||||||
|
<option key={w.name} value={w.name}>
|
||||||
|
{w.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm block mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
placeholder="Enter the task prompt..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm block mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Max Rounds
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxRounds}
|
||||||
|
onChange={(e) => setMaxRounds(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
className="w-24 px-3 py-2 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm" style={{ color: "var(--color-error)" }}>{error}</p>}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded border"
|
||||||
|
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !workflow || !prompt}
|
||||||
|
className="px-4 py-2 text-sm rounded"
|
||||||
|
style={{
|
||||||
|
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
|
||||||
|
color: "#fff",
|
||||||
|
opacity: !workflow || !prompt ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? "Starting..." : "Run"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { getHealth } from "../api.ts";
|
import { getHealth } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
export function StatusBar() {
|
type Props = {
|
||||||
|
onRun: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBar({ onRun }: Props) {
|
||||||
const health = useFetch(() => getHealth(), []);
|
const health = useFetch(() => getHealth(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -9,7 +13,16 @@ export function StatusBar() {
|
|||||||
className="flex items-center justify-between px-6 py-2 text-xs border-b"
|
className="flex items-center justify-between px-6 py-2 text-xs border-b"
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
>
|
>
|
||||||
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
|
<div className="flex items-center gap-4">
|
||||||
|
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
|
||||||
|
<button
|
||||||
|
onClick={onRun}
|
||||||
|
className="px-3 py-1 rounded text-xs font-medium"
|
||||||
|
style={{ background: "var(--color-accent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
▶ Run Thread
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span>
|
<span>
|
||||||
{health.status === "loading" && "⏳ Connecting..."}
|
{health.status === "loading" && "⏳ Connecting..."}
|
||||||
{health.status === "ok" && (
|
{health.status === "ok" && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getThread } from "../api.ts";
|
import { useState } from "react";
|
||||||
|
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -8,17 +9,60 @@ type Props = {
|
|||||||
|
|
||||||
export function ThreadDetail({ threadId, onBack }: Props) {
|
export function ThreadDetail({ threadId, onBack }: Props) {
|
||||||
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
|
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
|
||||||
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleAction(action: "kill" | "pause" | "resume") {
|
||||||
|
setActionStatus(`${action}ing...`);
|
||||||
|
try {
|
||||||
|
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||||
|
await fn(threadId);
|
||||||
|
setActionStatus(`${action} sent ✓`);
|
||||||
|
} catch (e) {
|
||||||
|
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<div className="flex items-center justify-between mb-4">
|
||||||
onClick={onBack}
|
<button
|
||||||
className="text-sm mb-4 hover:underline"
|
onClick={onBack}
|
||||||
style={{ color: "var(--color-accent)" }}
|
className="text-sm hover:underline"
|
||||||
>
|
style={{ color: "var(--color-accent)" }}
|
||||||
← Back to threads
|
>
|
||||||
</button>
|
← Back to threads
|
||||||
<h2 className="text-xl font-semibold mb-4 font-mono">{threadId}</h2>
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction("pause")}
|
||||||
|
className="px-3 py-1 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
|
||||||
|
>
|
||||||
|
⏸ Pause
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction("resume")}
|
||||||
|
className="px-3 py-1 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
|
||||||
|
>
|
||||||
|
▶ Resume
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction("kill")}
|
||||||
|
className="px-3 py-1 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
|
||||||
|
>
|
||||||
|
✕ Kill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold mb-2 font-mono">{threadId}</h2>
|
||||||
|
{actionStatus && (
|
||||||
|
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{actionStatus}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
|
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
|
||||||
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
|
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
|
||||||
|
|||||||
Reference in New Issue
Block a user