Files

337 lines
11 KiB
TypeScript

import { usePrompt } from "./drafts.js";
import { LocalInput, TextArea, Markdown } from "@quixos/camino-react/ui";
import { useState } from "react";
import { referenceOf, useCaminoQuery, useCrdtText, useInterface, type ExactReference } from "@quixos/camino-react";
import { interfaces, queries } from "./gen/client.js";
import { accepted, asInterface, errorMessage, type TaskNode } from "./model.js";
export function FeatureConversation({
tasks,
navigation,
}: {
library: ExactReference;
reader: ExactReference;
navigation: ExactReference;
tasks: TaskNode[];
}) {
if (!tasks.length) return null;
return (
<section className="lr-reader-threads" aria-label="Reader authoring threads">
<h3>Authoring threads</h3>
{tasks.map((task) => (
<FeatureThread key={referenceOf(task).object} task={task} navigation={navigation} />
))}
</section>
);
}
function FeatureThread({ task, navigation }: { task: TaskNode; navigation: ExactReference }) {
const [expanded, setExpanded] = useState(
["queued", "running", "waiting_input"].includes(task.thread?.active?.progress.status ?? task.data.status),
);
return (
<details
className="lr-authoring-thread"
open={expanded}
onToggle={(event) => setExpanded(event.currentTarget.open)}
>
<summary>
<span>
{snapshot(task.configuration)?.name ||
snapshot(task.configuration)?.instructions?.slice(0, 80) ||
"Annotator changes"}
</span>
<small>{task.thread?.active?.progress.status ?? task.data.status}</small>
</summary>
{expanded && <Conversation task={task} navigation={navigation} />}
</details>
);
}
export function authoringTitle(task: TaskNode) {
const config = snapshot(task.configuration);
if (config?.name) return config.name;
const name = task.thread?.data.title;
if (name && name.length <= 64) return name;
const types: Record<string, string> = {
overhead: "Text labels",
highlight: "Highlight",
sidebar: "Sidebar",
tooltip: "Tooltip",
replace: "Replacement",
};
return config?.style ? `${types[config.style] ?? config.style} annotator` : "New annotator";
}
type RequestSnapshot = {
name?: string;
style?: string;
reveal?: string;
color?: string;
fontFamily?: string;
fontSize?: number;
placement?: "above" | "below";
instructions?: string;
};
function snapshot(raw: string): RequestSnapshot | null {
try {
const value = JSON.parse(raw);
return value && typeof value === "object" ? value : null;
} catch {
return null;
}
}
export function RequestConfiguration({ value }: { value: RequestSnapshot }) {
return (
<article className="lr-request-config">
<strong>{value.name || "New Annotator"}</strong>
<dl>
{[
["Type", value.style === "overhead" ? "Text labels" : value.style],
[
"Position",
value.style === "overhead" ? (value.placement === "below" ? "Below the text" : "Above the text") : null,
],
[
"Show annotations",
value.reveal === "always"
? "Always"
: value.reveal === "hover"
? "When hovering"
: value.reveal === "click"
? "When clicked"
: value.reveal,
],
["Color", value.color],
["Font", value.fontFamily],
["Font size", value.fontSize],
]
.filter(([, value]) => value)
.map(([label, value]) => (
<div key={String(label)}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
{value.instructions && <p>{value.instructions}</p>}
</article>
);
}
/** Transcript rendering is independent of the graph controller. Embedded tools
* can supply a React view for their original structured request. */
export function ConversationTranscript({
items,
initial,
}: {
items: { key: string; kind: string; text: string }[];
initial?: React.ReactNode;
}) {
return (
<div className="lr-transcript" role="log" aria-live="polite">
{initial}
{items.map((item) => (
<div className={"lr-bubble lr-bubble-" + item.kind} key={item.key}>
<small>{item.kind === "user" ? "You" : "Agent"}</small>
{item.kind === "assistant" ? <Markdown text={item.text} /> : <p>{item.text}</p>}
</div>
))}
</div>
);
}
export function Conversation({ task, navigation }: { task: TaskNode; navigation: ExactReference }) {
const query = useTask(referenceOf(task));
if (query.status === "error")
return (
<p role="alert" className="lr-error">
Unable to load this conversation.
</p>
);
if (query.status !== "ready") return <p role="status">Loading conversation…</p>;
return <ConversationDetails task={query.data.root} navigation={navigation} />;
}
function useTask(reference: ExactReference) {
return useCaminoQuery(queries.ConversationView, {
root: asInterface(reference, "job"),
input: {},
admission: { query: queries.ConversationInputs, input: {} },
});
}
type DetailedTask = Extract<ReturnType<typeof useTask>, { status: "ready" }>["data"]["root"];
function ConversationDetails({ task, navigation }: { task: DetailedTask; navigation: ExactReference }) {
const draft = usePrompt(navigation, "followup");
const text = draft.text;
const thread = useInterface(
interfaces.AgentThread,
task.thread ? referenceOf(task.thread) : undefined,
!!task.thread,
);
const current = task.thread?.active ?? task.request;
const request = useInterface(interfaces.AgentRequest, current ? referenceOf(current) : undefined, !!current);
const [busy, setBusy] = useState(false),
[error, setError] = useState("");
const progress = current?.progress,
active = progress && !["completed", "failed", "cancelled", "uncertain"].includes(progress.status);
const config = snapshot(task.configuration);
const items = [...(task.thread?.items.entries ?? [])]
.sort((a, b) => Number(a.node.data.sequence) - Number(b.node.data.sequence))
.filter(({ node }) => ["user", "message", "assistant", "notice"].includes(node.data.kind) && node.data.text);
const original = items.find(
({ node }) => node.data.kind === "user" && node.request._camino.ref.object === task.request?._camino.ref.object,
)?.node.data.text;
async function send() {
setBusy(true);
setError("");
try {
await draft.flush();
if (active && request.status === "ready")
await accepted(
request.value.steer.call({
attempt: progress!.attempt,
generation: progress!.generation,
key: crypto.randomUUID(),
text,
profile: null,
createdAt: new Date().toISOString(),
}),
);
else if (thread.status === "ready")
await accepted(
thread.value.send.call({
text,
data: task.reader
? JSON.stringify({ authoringJob: referenceOf(task), reader: task.reader._camino.ref })
: null,
profile: null,
response: null,
sourceKind: "human",
sourceLabel: "Reading feature",
publication: null,
createdAt: new Date().toISOString(),
contexts: task.reader
? [
{
...task.reader._camino.ref,
expectation: { kind: "interface", id: "interface:org.quixos.agent:context-subject@1" },
},
]
: [],
related: null,
retryOf: null,
root: null,
causalDepth: 0,
}),
);
draft.clearSubmitted(text);
} catch (error) {
setError(errorMessage(error));
} finally {
setBusy(false);
}
}
return (
<section className="lr-conversation" aria-label="Feature conversation">
<header>
<strong>Annotator changes</strong>
<span role="status">
{active
? progress.status === "waiting_input"
? "Needs your answer"
: "Working on your feature…"
: progress?.status === "completed"
? "Agent finished"
: progress?.status || task.data.status}
</span>
{active && (
<button
onClick={() => {
if (request.status === "ready")
void accepted(request.value.cancel.call(null)).catch((error) => setError(errorMessage(error)));
}}
>
Stop
</button>
)}
</header>
<ConversationTranscript
items={items
.filter(
({ node }) =>
!(node.data.kind === "user" && node.request._camino.ref.object === task.request?._camino.ref.object),
)
.map(({ node }) => ({ key: referenceOf(node).object, kind: node.data.kind, text: node.data.text }))}
initial={
config ? (
<RequestConfiguration value={config} />
) : original ? (
<article className="lr-request-config">
<strong>Original request</strong>
<p>
{original.slice(0, 240)}
{original.length > 240 ? "…" : ""}
</p>
</article>
) : undefined
}
/>
{!items.length && task.data.output && <p>{task.data.output}</p>}
{(current?.questions.entries ?? [])
.filter(({ node }) => node.data.status === "open" || node.data.status === "pending")
.map(({ node }) => (
<Question key={referenceOf(node).object} question={node} />
))}
{(task.data.error || progress?.reason) && (
<p role="alert" className="lr-error">
{task.data.error || progress?.reason}
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<TextArea
binding={draft}
aria-label="Follow up on this feature"
rows={2}
maxLength={12000}
placeholder={active ? "Add a detail while it works…" : "Refine this feature…"}
/>
<button disabled={busy || !text.trim() || thread.status !== "ready"}>{active ? "Add detail" : "Send"}</button>
</form>
{error && <p role="alert">{error}</p>}
</section>
);
}
function Question({
question,
}: {
question: NonNullable<DetailedTask["request"]>["questions"]["entries"][number]["node"];
}) {
const api = useInterface(interfaces.AgentQuestion, referenceOf(question));
const [answer, setAnswer] = useState(""),
[error, setError] = useState("");
return (
<form
className="lr-question"
onSubmit={(event) => {
event.preventDefault();
if (api.status === "ready")
void accepted(
api.value.answer.call({
answer,
attempt: question.data.attempt,
generation: question.data.generation,
answeredAt: new Date().toISOString(),
}),
).catch((error) => setError(errorMessage(error)));
}}
>
<label className="lr-field">
{question.data.title}
<LocalInput value={answer} onChange={(event) => setAnswer(event.target.value)} />
</label>
<button disabled={!answer.trim()}>Answer</button>
{error && <p role="alert">{error}</p>}
</form>
);
}