Export template source from be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9:examples/local-first/packages/language-reader-presentation
This commit is contained in:
+336
@@ -0,0 +1,336 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user