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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user