/** * 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; }; } export interface ToolDefinition { type: "function"; function: { name: string; description: string; parameters: Record; }; } /** 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; } /** What a tool actually returned during a turn, kept for display and debugging. */ export interface ToolCallRecord { name: string; arguments: Record; result: string; isError: boolean; /** * 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[]; }