fabro/apps/fabro-web/app/lib/chats-runtime.ts
Bryan Helmkamp 75e5f34c0d
Expose lithos request and response shapes through the API, server, CLI, and web
The OpenAPI spec adopts the lithos request, response, content part,
tool, usage, and cost schemas. The completions endpoint returns the
lithos `Response` JSON verbatim and SSE carries lithos `StreamEvent`s
verbatim. The models and providers endpoints serve the fabro-types
catalog views, and the install and model-test flows probe providers
through fabro-llm.

The CLI builds its catalog from the operator overlay, drives `fabro exec`
through the server gateway adapter, and parses reasoning effort with the
shared controls. The web app reads content parts as lithos-tagged
objects. The TypeScript client is regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:26:57 -06:00

144 lines
3.8 KiB
TypeScript

import type {
ChatModelAdapter,
ChatModelRunResult,
ThreadAssistantMessagePart,
ThreadMessageLike,
} from "@assistant-ui/react";
import type {
Chat,
ChatContentPart,
ChatMessage,
JsonValue,
} from "./chats-types";
import { pickReply } from "./chats-script";
const STREAM_CHUNK_CHARS = 28;
const STREAM_CHUNK_INTERVAL_MS = 55;
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const handle = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(handle);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
}
function toolResultValue(content: readonly ChatContentPart[]): JsonValue {
const texts = content.flatMap((part) =>
part.type === "text" ? [part.text] : [],
);
return texts.length === content.length
? texts.join("")
: (JSON.parse(JSON.stringify(content)) as JsonValue);
}
function toAssistantParts(
content: readonly ChatContentPart[],
): ThreadAssistantMessagePart[] {
const out: ThreadAssistantMessagePart[] = [];
for (const part of content) {
if (part.type === "text") {
out.push({ type: "text", text: part.text });
} else if (part.type === "tool_call") {
out.push({
type: "tool-call",
toolCallId: part.id,
toolName: part.name,
args: part.input.arguments,
argsText: JSON.stringify(part.input.arguments),
});
} else if (part.type === "tool_result") {
for (let i = out.length - 1; i >= 0; i--) {
const candidate = out[i];
if (
candidate?.type === "tool-call" &&
candidate.toolCallId === part.tool_call_id
) {
out[i] = { ...candidate, result: toolResultValue(part.content) };
break;
}
}
}
}
return out;
}
export function createScriptedAdapter(args: {
getChat: () => Chat | undefined;
onReplyComplete: (reply: ChatMessage) => void;
}): ChatModelAdapter {
return {
async *run({ abortSignal }) {
const chat = args.getChat();
const reply = pickReply(chat?.scriptIndex ?? 0);
const accumulated: ChatContentPart[] = [];
for (const part of reply.content) {
if (part.type === "text") {
const text = part.text;
let cursor = 0;
accumulated.push({ type: "text", text: "" });
const accIndex = accumulated.length - 1;
while (cursor < text.length) {
cursor = Math.min(cursor + STREAM_CHUNK_CHARS, text.length);
accumulated[accIndex] = {
type: "text",
text: text.slice(0, cursor),
};
yield buildUpdate(accumulated);
if (cursor < text.length) {
await sleep(STREAM_CHUNK_INTERVAL_MS, abortSignal);
}
}
} else {
accumulated.push(part);
yield buildUpdate(accumulated);
await sleep(STREAM_CHUNK_INTERVAL_MS * 3, abortSignal);
}
}
args.onReplyComplete(reply);
},
};
}
function buildUpdate(parts: ChatContentPart[]): ChatModelRunResult {
return { content: toAssistantParts(parts) };
}
export function toThreadMessages(
messages: readonly ChatMessage[],
): ThreadMessageLike[] {
return messages.map((msg) => {
if (msg.role === "user") {
const content = [];
for (const part of msg.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text } as const);
}
}
return {
role: "user",
content,
};
}
if (msg.role === "assistant") {
return {
role: "assistant",
content: toAssistantParts(msg.content),
};
}
return { role: "system", content: [] };
});
}