// Request-body construction and dispatch for `POST /api/agent`. import type { Role } from "../../config/roles"; import { API_ROUTES } from "../../config/apiRoutes"; import { apiFetchRaw } from "../api"; import { errorMessage } from "../errors"; import { isNonEmptyString } from "../types"; /** Single attachment entry sent by the Vue UI on `POST /api/agent`. * Path-only — the Vue side never ships base64 bytes anymore. The * server reads the file from disk, infers the MIME type from the * extension, and produces a content block for Claude. Bridges * (Telegram / LINE / etc.) still send `{ mimeType, data }` over the * socket transport; both shapes share the same `Attachment` type * in `@mulmobridge/protocol`. */ export interface ClientAttachment { /** Workspace-relative path that exists under one of the allowed * roots (`artifacts/images/...` or `data/attachments/...`). */ path: string; /** Name the file had on the user's machine. The stored file keeps * its collision-proof hex name, so this is the only carrier of what * the user actually called it — the server announces it to the model * alongside the path (#2308). Absent for a file the user selected * rather than uploaded. */ filename?: string | undefined; } export interface AgentRequestBodyParams { message: string; role: Role; chatSessionId: string; /** Files the user has attached or selected for this turn, in * declaration order. Each is surfaced to the LLM as an * `[Attached file: ]` marker on the user message so * path-passing tools (e.g. `editImages`) can quote it back. * Empty / undefined when no file is attached. */ attachments?: readonly ClientAttachment[] | undefined; } export interface AgentRequestBody { message: string; roleId: string; chatSessionId: string; attachments: ClientAttachment[] | undefined; // IANA identifier (e.g. "Asia/Tokyo", "America/New_York"). The // server uses this to interpret bare time expressions in the user's // message without asking for clarification every turn. Undefined if // the browser can't resolve a timezone — the server then falls back // to its own local time and asks as before. userTimezone: string | undefined; } // `Intl.DateTimeFormat().resolvedOptions().timeZone` can, in theory, // throw in some locked-down environments; wrap so a broken Intl // doesn't take down the send path. function resolveBrowserTimezone(): string | undefined { try { const zoneId = Intl.DateTimeFormat().resolvedOptions().timeZone; return isNonEmptyString(zoneId) ? zoneId : undefined; } catch { return undefined; } } function buildAttachments(attachments: readonly ClientAttachment[] | undefined): ClientAttachment[] | undefined { if (!attachments || attachments.length === 0) return undefined; const entries: ClientAttachment[] = []; for (const candidate of attachments) { if (!candidate) continue; const { path, filename } = candidate; if (typeof path !== "string" || path.length === 0) continue; entries.push({ path, ...(isNonEmptyString(filename) ? { filename } : {}) }); } return entries.length > 0 ? entries : undefined; } export function buildAgentRequestBody(params: AgentRequestBodyParams): AgentRequestBody { return { message: params.message, roleId: params.role.id, chatSessionId: params.chatSessionId, attachments: buildAttachments(params.attachments), userTimezone: resolveBrowserTimezone(), }; } /** POST the agent request body and return the response. * On network or HTTP error, returns a descriptive error string * instead. The caller decides how to surface it. */ export async function postAgentRun(body: AgentRequestBody): Promise<{ ok: true } | { ok: false; error: string }> { try { const response = await apiFetchRaw(API_ROUTES.agent.run, { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }); if (!response.ok) { const errBody = await response.text().catch(() => ""); return { ok: false, error: `Server error ${response.status}: ${errBody.slice(0, 200)}`, }; } return { ok: true }; } catch (err) { console.error("[agent] fetch error:", err); return { ok: false, error: errorMessage(err, "Connection error."), }; } }