feat(agent): add llm assistant chat with api tools (task 88)

OpenAI-compatible client with mock fallback, tool-calling loop, and /assistant UI.

Tools map to /api/v1/ endpoints per ADR 0006 direct-tools decision.
This commit is contained in:
ginnoir
2026-07-04 19:46:09 -05:00
parent 04ae809e07
commit 4a924a4107
19 changed files with 956 additions and 6 deletions
+145
View File
@@ -0,0 +1,145 @@
type ApiCallResult = {
status: number;
body: unknown;
};
export type ToolExecutor = (name: string, argsJson: string) => Promise<string>;
export function createApiToolExecutor(request: Request): ToolExecutor {
const origin = new URL(request.url).origin;
return async (name: string, argsJson: string) => {
const args = parseArgs(argsJson);
const result = await dispatchTool(name, args, request, origin);
return JSON.stringify(result);
};
}
function parseArgs(argsJson: string): Record<string, unknown> {
if (!argsJson.trim()) return {};
const parsed: unknown = JSON.parse(argsJson);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Tool arguments must be a JSON object");
}
return parsed as Record<string, unknown>;
}
async function dispatchTool(
name: string,
args: Record<string, unknown>,
request: Request,
origin: string,
): Promise<ApiCallResult> {
switch (name) {
case "list_lists": {
const type = typeof args.type === "string" ? args.type : undefined;
const qs = type ? `?type=${encodeURIComponent(type)}` : "";
return callApi(request, origin, "GET", `/api/v1/lists${qs}`);
}
case "list_list_items": {
const listId = requireString(args, "listId");
return callApi(request, origin, "GET", `/api/v1/lists/${listId}/items`);
}
case "add_list_item": {
const text = requireString(args, "text");
let listId = typeof args.listId === "string" ? args.listId : undefined;
if (!listId) {
const listType = typeof args.listType === "string" ? args.listType : undefined;
const listsResult = await callApi(
request,
origin,
"GET",
listType ? `/api/v1/lists?type=${encodeURIComponent(listType)}` : "/api/v1/lists",
);
if (listsResult.status !== 200 || !Array.isArray(listsResult.body)) {
return listsResult;
}
const first = listsResult.body[0] as { id?: string } | undefined;
listId = first?.id;
if (!listId) {
return { status: 404, body: { error: "No matching list found" } };
}
}
const body: Record<string, unknown> = { text };
if (typeof args.qty === "string") body.qty = args.qty;
return callApi(request, origin, "POST", `/api/v1/lists/${listId}/items`, body);
}
case "list_calendars":
return callApi(request, origin, "GET", "/api/v1/calendars");
case "list_events": {
const from = requireString(args, "from");
const to = requireString(args, "to");
const calendarIds =
typeof args.calendarIds === "string" && args.calendarIds.length > 0
? args.calendarIds
: "all";
const qs = new URLSearchParams({ from, to, calendarIds });
return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`);
}
case "create_event": {
const body = {
calendarId: requireString(args, "calendarId"),
title: requireString(args, "title"),
startAt: requireString(args, "startAt"),
endAt: requireString(args, "endAt"),
allDay: typeof args.allDay === "boolean" ? args.allDay : false,
location: typeof args.location === "string" ? args.location : undefined,
notes: typeof args.notes === "string" ? args.notes : undefined,
};
return callApi(request, origin, "POST", "/api/v1/events", body);
}
case "list_notes":
return callApi(request, origin, "GET", "/api/v1/notes");
case "create_note": {
const body = {
title: requireString(args, "title"),
body: typeof args.body === "string" ? args.body : "",
pinned: typeof args.pinned === "boolean" ? args.pinned : false,
};
return callApi(request, origin, "POST", "/api/v1/notes", body);
}
case "list_journal_entries":
return callApi(request, origin, "GET", "/api/v1/journal/entries");
default:
return { status: 400, body: { error: `Unknown tool: ${name}` } };
}
}
function requireString(args: Record<string, unknown>, key: string): string {
const value = args[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Missing required argument: ${key}`);
}
return value;
}
async function callApi(
request: Request,
origin: string,
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<ApiCallResult> {
const response = await fetch(`${origin}${path}`, {
method,
headers: {
"Content-Type": "application/json",
cookie: request.headers.get("cookie") ?? "",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed: unknown = null;
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
}
return { status: response.status, body: parsed };
}