607 lines
22 KiB
TypeScript
607 lines
22 KiB
TypeScript
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|||
|
|
import {
|
||
|
|
type Item,
|
||
|
|
type Request,
|
||
|
|
type Question,
|
||
|
|
type ThreadController,
|
||
|
|
type Profiles,
|
||
|
|
itemKey,
|
||
|
|
terminal,
|
||
|
|
} from "./protocol.js";
|
||
|
|
import { LocalInput, LocalTextArea, LocalSelect, Markdown } from "@quixos/camino-react/ui";
|
||
|
|
import { draftKey } from "./http.js";
|
||
|
|
|
||
|
|
const labels: Record<string, string> = {
|
||
|
|
queued: "Queued",
|
||
|
|
blocked: "Waiting to start",
|
||
|
|
running: "Working",
|
||
|
|
waiting_input: "Needs your answer",
|
||
|
|
recovering: "Checking execution status",
|
||
|
|
recording: "Recording result",
|
||
|
|
pending_graph: "Awaiting graph recording",
|
||
|
|
awaiting_graph: "Awaiting graph recording",
|
||
|
|
completed: "Completed",
|
||
|
|
failed: "Failed",
|
||
|
|
cancelled: "Cancelled",
|
||
|
|
uncertain: "Outcome uncertain",
|
||
|
|
parked: "Needs your answer",
|
||
|
|
};
|
||
|
|
function ResultValue({ value }: { value: unknown }) {
|
||
|
|
if (Array.isArray(value))
|
||
|
|
return (
|
||
|
|
<ol>
|
||
|
|
{value.map((entry, index) => (
|
||
|
|
<li key={index}>
|
||
|
|
<ResultValue value={entry} />
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ol>
|
||
|
|
);
|
||
|
|
if (value && typeof value === "object")
|
||
|
|
return (
|
||
|
|
<dl>
|
||
|
|
{Object.entries(value).map(([key, entry]) => (
|
||
|
|
<React.Fragment key={key}>
|
||
|
|
<dt>{key}</dt>
|
||
|
|
<dd>
|
||
|
|
<ResultValue value={entry} />
|
||
|
|
</dd>
|
||
|
|
</React.Fragment>
|
||
|
|
))}
|
||
|
|
</dl>
|
||
|
|
);
|
||
|
|
return <span>{value === null ? "null" : String(value)}</span>;
|
||
|
|
}
|
||
|
|
export function StructuredResult({ value }: { value: string }) {
|
||
|
|
let parsed: unknown;
|
||
|
|
try {
|
||
|
|
parsed = JSON.parse(value);
|
||
|
|
} catch {
|
||
|
|
return <pre>{value}</pre>;
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<div className="agent-result" aria-label="Structured result">
|
||
|
|
<ResultValue value={parsed} />
|
||
|
|
<details>
|
||
|
|
<summary>Raw JSON</summary>
|
||
|
|
<pre>{value}</pre>
|
||
|
|
<button onClick={() => void navigator.clipboard.writeText(value)}>Copy JSON</button>
|
||
|
|
</details>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
function QuestionForm({
|
||
|
|
question,
|
||
|
|
request,
|
||
|
|
controller,
|
||
|
|
}: {
|
||
|
|
question: Question;
|
||
|
|
request: Request;
|
||
|
|
controller: ThreadController;
|
||
|
|
}) {
|
||
|
|
const [answer, setAnswer] = useState(""),
|
||
|
|
[busy, setBusy] = useState(false),
|
||
|
|
[error, setError] = useState("");
|
||
|
|
let options: string[] = [];
|
||
|
|
try {
|
||
|
|
options = JSON.parse(question.data.options);
|
||
|
|
} catch {}
|
||
|
|
const open =
|
||
|
|
question.data.status === "open" &&
|
||
|
|
!terminal.has(request.progress.status) &&
|
||
|
|
!request.progress.cancelRequested &&
|
||
|
|
Date.parse(question.data.expiresAt) > Date.now();
|
||
|
|
return (
|
||
|
|
<form
|
||
|
|
className="agent-question"
|
||
|
|
onSubmit={(event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
if (!open || !answer.trim() || busy) return;
|
||
|
|
setBusy(true);
|
||
|
|
setError("");
|
||
|
|
void controller
|
||
|
|
.control({
|
||
|
|
kind: "answer",
|
||
|
|
question: question._camino.ref.object,
|
||
|
|
attempt: question.data.attempt,
|
||
|
|
generation: question.data.generation,
|
||
|
|
answer,
|
||
|
|
})
|
||
|
|
.catch((error) => setError(String(error)))
|
||
|
|
.finally(() => setBusy(false));
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<fieldset disabled={!open || busy}>
|
||
|
|
<legend>{question.data.title}</legend>
|
||
|
|
{options.map((option) => (
|
||
|
|
<label key={option}>
|
||
|
|
<LocalInput
|
||
|
|
type="radio"
|
||
|
|
name={question._camino.ref.object}
|
||
|
|
value={option}
|
||
|
|
checked={answer === option}
|
||
|
|
onChange={() => setAnswer(option)}
|
||
|
|
/>
|
||
|
|
{option}
|
||
|
|
</label>
|
||
|
|
))}
|
||
|
|
<label>
|
||
|
|
Your answer
|
||
|
|
<LocalInput aria-label="Your answer" value={answer} onChange={(event) => setAnswer(event.target.value)} />
|
||
|
|
</label>
|
||
|
|
<button disabled={!answer.trim()}>{busy ? "Sending answer…" : "Answer"}</button>
|
||
|
|
</fieldset>
|
||
|
|
{!open && (
|
||
|
|
<p>
|
||
|
|
{question.data.answer
|
||
|
|
? `Answered: ${question.data.answer}`
|
||
|
|
: question.data.status === "open"
|
||
|
|
? "This question is no longer accepting answers."
|
||
|
|
: question.data.status}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{error && <p role="alert">{error}</p>}
|
||
|
|
</form>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
function Activity({ item }: { item: Item }) {
|
||
|
|
let value: any;
|
||
|
|
try {
|
||
|
|
value = JSON.parse(item.data.activity ?? "null");
|
||
|
|
} catch {}
|
||
|
|
const title = value?.command ?? value?.label ?? item.data.text ?? "Activity";
|
||
|
|
return (
|
||
|
|
<details className="agent-activity-card">
|
||
|
|
<summary>{String(title).slice(0, 300)}</summary>
|
||
|
|
<pre>{value?.aggregatedOutput ?? value?.output ?? value?.detail ?? item.data.text}</pre>
|
||
|
|
{value?.exitCode !== undefined && <p>Exit code: {value.exitCode}</p>}
|
||
|
|
</details>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
function RequestCard({
|
||
|
|
request,
|
||
|
|
controller,
|
||
|
|
inspection,
|
||
|
|
}: {
|
||
|
|
request: Request;
|
||
|
|
controller: ThreadController;
|
||
|
|
inspection: boolean;
|
||
|
|
}) {
|
||
|
|
const [busy, setBusy] = useState(false),
|
||
|
|
[error, setError] = useState(""),
|
||
|
|
[details, setDetails] = useState<unknown>();
|
||
|
|
const p = request.progress,
|
||
|
|
cancel = () => {
|
||
|
|
setBusy(true);
|
||
|
|
setError("");
|
||
|
|
const action =
|
||
|
|
request.pendingGraph && request.repair
|
||
|
|
? import("./http.js").then(({ api }) =>
|
||
|
|
api("/repair-control", {
|
||
|
|
operationId: crypto.randomUUID(),
|
||
|
|
repair: request.repair,
|
||
|
|
attempt: p.attempt,
|
||
|
|
generation: p.generation,
|
||
|
|
kind: "cancel",
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
: controller.control({ kind: "cancel", request: request._camino.ref.object });
|
||
|
|
void action.catch((error) => setError(String(error))).finally(() => setBusy(false));
|
||
|
|
};
|
||
|
|
return (
|
||
|
|
<footer className={`agent-request-card agent-request-${p.status}`} data-request={request._camino.ref.object}>
|
||
|
|
<div>
|
||
|
|
{(inspection || p.status !== "completed") && <strong>{labels[p.status] ?? p.status}</strong>}
|
||
|
|
{request.pendingGraph && <span> · Awaiting graph recording</span>}
|
||
|
|
{p.cancelRequested && !terminal.has(p.status) && <span> · Stop requested</span>}
|
||
|
|
{!terminal.has(p.status) && (
|
||
|
|
<button disabled={busy || p.cancelRequested || (request.pendingGraph && !p.attempt)} onClick={cancel}>
|
||
|
|
Stop request
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
{!request.pendingGraph && (inspection || p.status !== "completed") && (
|
||
|
|
<button
|
||
|
|
onClick={() => {
|
||
|
|
void controller
|
||
|
|
.details(request._camino.ref.object)
|
||
|
|
.then(setDetails)
|
||
|
|
.catch((error) => setError(String(error)));
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Request details
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{p.reason && <p>{p.reason}</p>}
|
||
|
|
{p.status === "uncertain" && <p>Execution may have occurred. Check activity before starting another request.</p>}
|
||
|
|
{p.status === "completed" && request.admission?.responseKind === "json" && p.result && (
|
||
|
|
<StructuredResult value={p.result} />
|
||
|
|
)}
|
||
|
|
{p.status === "completed" && request.admission?.responseKind === "unit" && <p>Actions completed.</p>}
|
||
|
|
{["failed", "cancelled", "uncertain"].includes(p.status) && request.admission?.text && (
|
||
|
|
<button
|
||
|
|
onClick={() => {
|
||
|
|
setBusy(true);
|
||
|
|
void controller
|
||
|
|
.submit(request.admission!.text!, { retryOf: request._camino.ref.object })
|
||
|
|
.catch((error) => setError(String(error)))
|
||
|
|
.finally(() => setBusy(false));
|
||
|
|
}}
|
||
|
|
disabled={busy}
|
||
|
|
>
|
||
|
|
Try again with current settings
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
{p.status === "failed" && p.rawOutput && (
|
||
|
|
<details>
|
||
|
|
<summary>Returned output</summary>
|
||
|
|
<pre>{p.rawOutput}</pre>
|
||
|
|
</details>
|
||
|
|
)}
|
||
|
|
{details !== undefined && (
|
||
|
|
<details open>
|
||
|
|
<summary>Captured request and execution</summary>
|
||
|
|
<pre>{JSON.stringify(details, null, 2)}</pre>
|
||
|
|
</details>
|
||
|
|
)}
|
||
|
|
{error && <p role="alert">{error}</p>}
|
||
|
|
</footer>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
export function ThreadView({
|
||
|
|
controller,
|
||
|
|
thread,
|
||
|
|
profiles = {},
|
||
|
|
workspace = false,
|
||
|
|
defaultProfile = "balanced",
|
||
|
|
allowRepair = false,
|
||
|
|
renderWidget,
|
||
|
|
renderItem,
|
||
|
|
}: {
|
||
|
|
controller: ThreadController;
|
||
|
|
thread: string;
|
||
|
|
profiles?: Profiles;
|
||
|
|
workspace?: boolean;
|
||
|
|
defaultProfile?: string;
|
||
|
|
allowRepair?: boolean;
|
||
|
|
renderWidget?(item: Item): React.ReactNode;
|
||
|
|
/** Return undefined for normal rendering; null hides an item. */
|
||
|
|
renderItem?(item: Item, request: Request): React.ReactNode | undefined;
|
||
|
|
}) {
|
||
|
|
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot),
|
||
|
|
[draft, setDraft] = useState(() => sessionStorage.getItem(draftKey(thread)) ?? ""),
|
||
|
|
[busy, setBusy] = useState(false),
|
||
|
|
[error, setError] = useState(""),
|
||
|
|
[profile, setProfile] = useState(() => sessionStorage.getItem(`${draftKey(thread)}:profile`) ?? ""),
|
||
|
|
[response, setResponse] = useState(""),
|
||
|
|
[repair, setRepair] = useState(false),
|
||
|
|
[latest, setLatest] = useState(true),
|
||
|
|
[now, setNow] = useState(Date.now()),
|
||
|
|
scroll = useRef<HTMLDivElement>(null),
|
||
|
|
previousSize = useRef(0);
|
||
|
|
useEffect(() => {
|
||
|
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||
|
|
return () => clearInterval(timer);
|
||
|
|
}, []);
|
||
|
|
useEffect(() => {
|
||
|
|
sessionStorage.setItem(draftKey(thread), draft);
|
||
|
|
}, [thread, draft]);
|
||
|
|
useEffect(() => {
|
||
|
|
if (latest && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
|
||
|
|
}, [state.items, latest]);
|
||
|
|
const active =
|
||
|
|
(state.thread?.active &&
|
||
|
|
state.requests.find(
|
||
|
|
(request) =>
|
||
|
|
request._camino.ref.object === state.thread!.active!._camino.ref.object &&
|
||
|
|
!terminal.has(request.progress.status),
|
||
|
|
)) ??
|
||
|
|
state.requests.find((request) =>
|
||
|
|
["running", "waiting_input", "stopping", "parked"].includes(request.progress.status),
|
||
|
|
),
|
||
|
|
pending = state.requests.filter((request) => !terminal.has(request.progress.status)),
|
||
|
|
archived = state.thread?.data.archived;
|
||
|
|
const run = async (steer = false) => {
|
||
|
|
if (!draft.trim() || busy) return;
|
||
|
|
setBusy(true);
|
||
|
|
setError("");
|
||
|
|
const text = draft;
|
||
|
|
try {
|
||
|
|
if (steer && active)
|
||
|
|
await controller.control({
|
||
|
|
kind: "steer",
|
||
|
|
request: active._camino.ref.object,
|
||
|
|
attempt: active.progress.attempt,
|
||
|
|
generation: active.progress.generation,
|
||
|
|
text,
|
||
|
|
});
|
||
|
|
else
|
||
|
|
await controller.submit(text, {
|
||
|
|
...(profile ? { profile } : {}),
|
||
|
|
...(response ? { response: { kind: response } } : {}),
|
||
|
|
repair: state.maintenance && repair,
|
||
|
|
});
|
||
|
|
setDraft((current) => (current === text ? "" : current));
|
||
|
|
} catch (error) {
|
||
|
|
setError(String(error));
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const selected = profile || defaultProfile;
|
||
|
|
const route = profiles[selected];
|
||
|
|
const standard = route?.standardProfile ?? selected;
|
||
|
|
const changeProfile = async (next: string) => {
|
||
|
|
setProfile(next);
|
||
|
|
sessionStorage.setItem(`${draftKey(thread)}:profile`, next);
|
||
|
|
if (active?.progress.status !== "running" || state.maintenance) return;
|
||
|
|
setBusy(true);
|
||
|
|
setError("");
|
||
|
|
try {
|
||
|
|
await controller.control({
|
||
|
|
kind: "steer",
|
||
|
|
request: active._camino.ref.object,
|
||
|
|
attempt: active.progress.attempt,
|
||
|
|
generation: active.progress.generation,
|
||
|
|
text: "",
|
||
|
|
profile: next,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
setError(String(error));
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const grouped = new Map<string, { request: Request; items: Item[] }>();
|
||
|
|
for (const item of state.items) {
|
||
|
|
const key = item.request._camino.ref.object,
|
||
|
|
group = grouped.get(key) ?? { request: item.request, items: [] };
|
||
|
|
group.items.push(item);
|
||
|
|
group.request = item.request;
|
||
|
|
grouped.set(key, group);
|
||
|
|
}
|
||
|
|
for (const request of pending)
|
||
|
|
if (!grouped.has(request._camino.ref.object)) grouped.set(request._camino.ref.object, { request, items: [] });
|
||
|
|
return (
|
||
|
|
<section className="agent-thread" aria-label="Agent conversation">
|
||
|
|
<div className="agent-thread-status" role="status">
|
||
|
|
{state.loading
|
||
|
|
? "Loading thread…"
|
||
|
|
: state.maintenance
|
||
|
|
? "Workspace graph unavailable. Known activity remains visible."
|
||
|
|
: state.connected
|
||
|
|
? "Connected"
|
||
|
|
: "Reconnecting…"}
|
||
|
|
{active?.progress.startedAt && (
|
||
|
|
<span> · Working for {Math.max(0, Math.floor((now - Date.parse(active.progress.startedAt)) / 1000))}s</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
className="agent-thread-history"
|
||
|
|
ref={scroll}
|
||
|
|
onScroll={() => {
|
||
|
|
const element = scroll.current!;
|
||
|
|
setLatest(element.scrollHeight - element.scrollTop - element.clientHeight < 80);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{state.pageInfo?.hasNextPage && (
|
||
|
|
<button
|
||
|
|
onClick={() => {
|
||
|
|
const element = scroll.current!,
|
||
|
|
height = element.scrollHeight;
|
||
|
|
previousSize.current = height;
|
||
|
|
setLatest(false);
|
||
|
|
void controller
|
||
|
|
.refresh(true)
|
||
|
|
.then(() =>
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
element.scrollTop += element.scrollHeight - previousSize.current;
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
.catch((error) => setError(String(error)));
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Load earlier messages
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
{grouped.size === 0 && !state.loading && (
|
||
|
|
<div className="agent-empty">
|
||
|
|
<h2>A new conversation</h2>
|
||
|
|
<p>Send a message or a task to this Agent.</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{[...grouped.values()].map(({ request, items }) => (
|
||
|
|
<article className="agent-request" key={request._camino.ref.object}>
|
||
|
|
<div className="agent-request-source">
|
||
|
|
{request.admission?.sourceLabel ?? "Agent request"} ·{" "}
|
||
|
|
{request.admission?.createdAt && (
|
||
|
|
<time dateTime={request.admission.createdAt}>
|
||
|
|
{new Date(request.admission.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||
|
|
</time>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{items.map((item) => {
|
||
|
|
const custom = renderItem?.(item, request);
|
||
|
|
return (
|
||
|
|
<React.Fragment key={itemKey(item)}>
|
||
|
|
{custom !== undefined ? (
|
||
|
|
custom
|
||
|
|
) : item.data.kind === "steering" && item.data.activity ? (
|
||
|
|
<aside className="agent-routing-change" role="status">
|
||
|
|
Model profile · {JSON.parse(item.data.activity).profile} ·{" "}
|
||
|
|
{item.data.control?.status === "accepted"
|
||
|
|
? "Applied to subsequent inference"
|
||
|
|
: item.data.control?.status === "rejected"
|
||
|
|
? "Change rejected by Codex"
|
||
|
|
: item.data.control?.status === "uncertain"
|
||
|
|
? "Change outcome uncertain"
|
||
|
|
: "Changing…"}
|
||
|
|
{item.data.control?.reason && <p>{item.data.control.reason}</p>}
|
||
|
|
{item.data.text && <p>{item.data.text}</p>}
|
||
|
|
</aside>
|
||
|
|
) : item.data.kind === "activity" ? (
|
||
|
|
<Activity item={item} />
|
||
|
|
) : item.data.kind === "widget" ? (
|
||
|
|
<div className="agent-widget">
|
||
|
|
{renderWidget ? (
|
||
|
|
renderWidget(item)
|
||
|
|
) : (
|
||
|
|
<p>
|
||
|
|
Widget unavailable in this view.{" "}
|
||
|
|
<a href={`/entity/${encodeURIComponent(item.subject?._camino.ref.object ?? "")}`}>
|
||
|
|
Open object
|
||
|
|
</a>
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : item.data.kind === "status" ? (
|
||
|
|
<aside className="agent-status-card">
|
||
|
|
{item.data.text}
|
||
|
|
<Activity item={item} />
|
||
|
|
</aside>
|
||
|
|
) : item.data.phase === "commentary" ? (
|
||
|
|
<details className="agent-commentary">
|
||
|
|
<summary>Agent commentary</summary>
|
||
|
|
<Markdown text={item.data.text} />
|
||
|
|
</details>
|
||
|
|
) : request.admission?.responseKind === "json" && item.data.kind === "assistant" ? null : (
|
||
|
|
<div className={`agent-message agent-message-${item.data.kind}`}>
|
||
|
|
{item.data.kind === "assistant" ? <Markdown text={item.data.text} /> : item.data.text}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</React.Fragment>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
{(request.questions?.entries ?? []).map(({ node }) => (
|
||
|
|
<QuestionForm key={node._camino.ref.object} question={node} request={request} controller={controller} />
|
||
|
|
))}
|
||
|
|
<RequestCard request={request} controller={controller} inspection={!workspace} />
|
||
|
|
</article>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
{!latest && (
|
||
|
|
<button
|
||
|
|
className="agent-jump"
|
||
|
|
onClick={() => {
|
||
|
|
setLatest(true);
|
||
|
|
if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Jump to latest
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<form
|
||
|
|
className="agent-thread-composer"
|
||
|
|
onSubmit={(event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
void run();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{state.maintenance && allowRepair && (
|
||
|
|
<label>
|
||
|
|
<LocalInput type="checkbox" checked={repair} onChange={(event) => setRepair(event.target.checked)} />
|
||
|
|
Source repair in the Workspace thread
|
||
|
|
</label>
|
||
|
|
)}
|
||
|
|
<label className="agent-compose-label">
|
||
|
|
Message
|
||
|
|
<LocalTextArea
|
||
|
|
aria-label="Message"
|
||
|
|
rows={3}
|
||
|
|
value={draft}
|
||
|
|
disabled={archived || busy}
|
||
|
|
onChange={(event) => setDraft(event.target.value)}
|
||
|
|
onKeyDown={(event) => {
|
||
|
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||
|
|
event.preventDefault();
|
||
|
|
void run();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<div className="agent-compose-actions">
|
||
|
|
{workspace && Object.keys(profiles).length > 0 && (
|
||
|
|
<div className="agent-model-controls">
|
||
|
|
<label>
|
||
|
|
Model
|
||
|
|
<LocalSelect
|
||
|
|
aria-label="Model"
|
||
|
|
disabled={busy}
|
||
|
|
value={standard}
|
||
|
|
onChange={(event) => {
|
||
|
|
const name = event.target.value;
|
||
|
|
void changeProfile(
|
||
|
|
route?.standardProfile && profiles[name]?.fastProfile ? profiles[name].fastProfile! : name,
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{Object.entries(profiles)
|
||
|
|
.filter(([, p]) => !p.standardProfile)
|
||
|
|
.map(([name, p]) => (
|
||
|
|
<option key={name} value={name} disabled={p.available === false}>
|
||
|
|
{p.description ?? p.model ?? name}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</LocalSelect>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<LocalInput
|
||
|
|
type="checkbox"
|
||
|
|
aria-label="Fast mode"
|
||
|
|
checked={!!route?.standardProfile}
|
||
|
|
disabled={busy || !(route?.fastProfile || route?.standardProfile)}
|
||
|
|
onChange={(event) =>
|
||
|
|
void changeProfile(event.target.checked ? route!.fastProfile! : route!.standardProfile!)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
Fast
|
||
|
|
</label>
|
||
|
|
{active?.progress.status === "running" && <small>Changes apply between inference steps.</small>}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<details>
|
||
|
|
<summary>Next request options</summary>
|
||
|
|
<label>
|
||
|
|
Profile
|
||
|
|
<LocalSelect aria-label="Profile" value={profile} onChange={(event) => setProfile(event.target.value)}>
|
||
|
|
<option value="">Agent default</option>
|
||
|
|
{Object.keys(profiles).map((value) => (
|
||
|
|
<option key={value} disabled={profiles[value].available === false}>
|
||
|
|
{value}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</LocalSelect>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
Response
|
||
|
|
<LocalSelect aria-label="Response" value={response} onChange={(event) => setResponse(event.target.value)}>
|
||
|
|
<option value="">Agent default</option>
|
||
|
|
<option value="text">Text follow-up</option>
|
||
|
|
<option value="unit">Actions only</option>
|
||
|
|
</LocalSelect>
|
||
|
|
</label>
|
||
|
|
</details>
|
||
|
|
<span>{pending.length ? `${pending.length} outstanding request${pending.length === 1 ? "" : "s"}` : ""}</span>
|
||
|
|
{active?.progress.status === "running" && !state.maintenance && (
|
||
|
|
<button type="button" disabled={!draft.trim() || busy} onClick={() => void run(true)}>
|
||
|
|
Steer current
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<button disabled={!draft.trim() || busy || archived || (state.maintenance && !(allowRepair && repair))}>
|
||
|
|
{busy ? "Sending…" : state.maintenance ? "Submit source repair" : pending.length ? "Send to queue" : "Send"}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{archived && <p>This thread is archived. Reopen it to send a message.</p>}
|
||
|
|
{(error || state.error) && (
|
||
|
|
<p role="alert">
|
||
|
|
{error || state.error}{" "}
|
||
|
|
<button type="button" onClick={() => void run()}>
|
||
|
|
Retry retained submission
|
||
|
|
</button>
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</form>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|