337 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|