Files
lam-agent-vicinae/src/lib/types.ts
T

265 lines
8.9 KiB
TypeScript

/**
* Shared domain types for Lam Agent.
*
* These are deliberately split into two families:
*
* - `ApiMessage` and friends mirror exactly what Ollama's `/api/chat` accepts.
* - `Session`, `ChatTurn` and friends are Lam's own storage model.
*
* Keeping them separate matters because the storage model is derived from the
* API model, not identical to it: the tool-calling messages that Ollama needs
* mid-turn are not persisted, so a reloaded session replays cleanly as plain
* user/assistant pairs.
*/
export type ChatRole = "system" | "user" | "assistant" | "tool";
export interface ToolCall {
function: {
name: string;
arguments: Record<string, unknown>;
};
}
export interface ToolDefinition {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
/** A message in the shape Ollama's `/api/chat` endpoint expects. */
export interface ApiMessage {
role: ChatRole;
content: string;
tool_calls?: ToolCall[];
/** Only set on `role: "tool"` messages, naming the tool that produced this content. */
tool_name?: string;
/**
* Base64 image data, for vision models.
*
* Ollama only reads these on a `user` message (`gpt-oss` style tool roles have
* no images). They are the one genuinely large thing in a request, so they are
* attached to the question they belong to and never replayed later, the same
* way tool output is not replayed.
*/
images?: string[];
}
/**
* A file the user attached to a question.
*
* Only the path is stored. A screenshot runs to megabytes, and
* `MAX_STORED_CHARS_PER_TURN` exists precisely so one turn cannot dominate
* LocalStorage, so the bytes stay on disk and are read when the turn is sent.
*/
export interface Attachment {
/** Absolute path to the image on disk. */
path: string;
/** Sent to Ollama next to the base64 payload. */
mime: string;
/**
* Pixel dimensions, read from the file's header.
*
* Kept because the transcript has to lay the preview out long after the file
* was read, and Vicinae sizes an image's container to the request rather than
* to the image, which leaves dead space unless the request has the right
* aspect ratio. Absent for a format whose header we do not parse.
*/
width?: number;
height?: number;
}
/**
* An attachment that has been read and is ready to send, held in the view while
* the user types their question.
*
* The base64 payload lives here and nowhere else. It is needed for exactly one
* request, so it is deliberately not part of `Attachment` and never reaches
* storage.
*/
export interface PendingAttachment extends Attachment {
/** Base64 of the image, with no `data:` URL prefix. Ollama wants it bare. */
base64: string;
}
/**
* One numbered source, as a web tool found it.
*
* The number is the whole point: it is assigned once per turn, printed to the
* model in the tool result, and rendered as the marker the reader clicks. The
* model and the reader therefore agree on what `[3]` means without either of them
* being asked to remember a URL.
*/
export interface Source {
/** The number the model cites. One-based, and never reused for a different page. */
index: number;
/** Page title, or the URL when the page had none. */
title: string;
url: string;
}
/** What a tool actually returned during a turn, kept for display and debugging. */
export interface ToolCallRecord {
name: string;
arguments: Record<string, unknown>;
result: string;
isError: boolean;
/**
* The sources this call added to the turn, if it consulted any.
*
* Only what this call found, never the turn's running total: the whole list is
* the flattening of a turn's records, so storing the total here would write
* the same page once per later call.
*
* Absent on turns stored before sources were numbered, which is honest rather
* than empty: those answers never cited a number, so there is nothing to draw.
*/
sources?: Source[];
/**
* Set when the call was refused because the turn had already made it twice.
*
* Stored rather than inferred from the message text, so the transcript can say
* what happened in its own words, and so a repeat that ended a turn stays
* visible even when the user has hidden tool calls from the transcript.
*/
repeated?: boolean;
}
/** One question and its answer. The unit the transcript is rendered from. */
export interface ChatTurn {
id: string;
prompt: string;
response: string;
thinking: string;
model: string;
createdAt: number;
/** Set when the turn failed, so a stored session can show what went wrong. */
error?: string;
toolCalls: ToolCallRecord[];
/** Images the user attached, kept for the transcript. The bytes are not stored. */
attachments: Attachment[];
}
export interface Session {
id: string;
title: string;
model: string;
createdAt: number;
updatedAt: number;
turns: ChatTurn[];
}
/** The lightweight record kept in the session index, so listing sessions never loads full transcripts. */
export interface SessionSummary {
id: string;
title: string;
model: string;
createdAt: number;
updatedAt: number;
turnCount: number;
preview: string;
/**
* Every image path any turn in this conversation refers to.
*
* Kept here rather than derived from the turns for one reason: the orphan
* sweep needs to know which snips are still spoken for, and loading every
* transcript to find out would mean a hundred JSON parses on every turn.
*
* Absent on entries written before this field existed, which is deliberately
* not the same as an empty array: an absent value means "unknown", and the
* sweep resolves it by reading that one transcript rather than by assuming the
* conversation has no screenshots and deleting them.
*/
attachmentPaths?: string[];
}
/**
* Whether the model in use can read images, as far as the capability check knows.
*
* `"unknown"` is deliberately distinct from `"no"`: a failed request is not
* evidence that a model cannot see, and reporting it as one would be a false
* claim about the user's setup. Lives here rather than in `vision.ts` so that
* modules which must stay free of Vicinae imports can name it.
*/
export type VisionSupport = "yes" | "no" | "unknown";
/**
* The tool running right now, and how stale it is getting.
*
* Lives here rather than beside the chat state machine so that `thread.ts` can
* carry it into the transcript, which is the only place it is now rendered.
*/
export interface ToolActivity {
/** Human-readable label, e.g. "Searching the web". */
label: string;
/**
* How many times this turn has now started this *identical* call.
*
* Counted by the agent over name and arguments together, so two different
* searches are two tools doing their job rather than a repeat.
*/
count: number;
}
/** A model as reported by `GET /api/tags`. */
export interface ModelInfo {
name: string;
parameterSize?: string;
/**
* How much disk the model takes, in bytes.
*
* Meaningless for a cloud model proxied through a local daemon: that entry is
* a few hundred bytes of manifest, so the caller shows nothing rather than
* "326 B", which is what `ollama list` does too.
*/
size?: number;
/**
* What the model says it can do, e.g. `["tools", "vision"]`.
*
* Only newer servers send this. Absent means unknown, never "no": reporting a
* missing field as a lack of vision would be a false claim about the user's
* setup.
*/
capabilities?: string[];
/**
* Set when this entry is a proxy rather than weights on disk, naming the server
* that actually runs it (`https://ollama.com`). Deleting one of these drops a
* reference; it does not free the gigabytes the size column implies.
*/
remoteHost?: string;
}
/**
* One rendered line of the conversation.
*
* A turn is stored as a question/answer pair, but the transcript is displayed as
* a flat sequence of messages so each side can carry its own icon and weight.
*/
export interface ThreadMessage {
id: string;
role: "user" | "assistant";
/** Full text, used for the detail panel and for copying. */
text: string;
createdAt: number;
isStreaming: boolean;
thinking: string;
toolCalls: ToolCallRecord[];
error?: string;
/** Activity line shown while this message is streaming, e.g. "Searching the web". */
status?: string;
/**
* The tool this message is waiting on, when there is one.
*
* Separate from {@link ThreadMessage.status} because the two are rendered from
* different triggers: the status is a stand-in used only while there is no text
* to read, while a running tool is worth saying whether or not tokens have
* started arriving. Carrying both is what lets one line be printed for one
* fact, rather than the same sentence appearing twice at the top of a turn.
*/
activity?: ToolActivity;
/** Images attached to this question. Only ever non-empty on a user message. */
attachments: Attachment[];
}