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
@@ -0,0 +1,134 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type ChatMessage = {
role: "user" | "assistant";
content: string;
};
type Props = {
configured: boolean;
};
export function AssistantChat({ configured }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const listRef = useRef<HTMLDivElement>(null);
function scrollToBottom() {
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight;
});
}
function sendMessage() {
const text = input.trim();
if (!text || isPending) return;
const nextMessages: ChatMessage[] = [...messages, { role: "user", content: text }];
setInput("");
setError(null);
setMessages(nextMessages);
scrollToBottom();
startTransition(async () => {
try {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages }),
});
const payload = (await response.json()) as {
error?: string;
message?: ChatMessage;
};
if (!response.ok) {
throw new Error(payload.error ?? "Assistant request failed");
}
if (!payload.message?.content) {
throw new Error("Assistant returned an empty response");
}
setMessages((current) => [...current, payload.message!]);
scrollToBottom();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
}
});
}
return (
<div className="mx-auto flex w-full max-w-2xl min-h-[70vh] flex-col gap-4 min-w-0">
<div>
<h2 className="serif text-[22px] tracking-tight">Assistant</h2>
<p className="muted text-[13px] mt-1">
{configured
? "Connected to your homelab LLM. I can update lists, calendar, notes, and journal via the API."
: "LLM endpoint not configured — using the built-in mock provider for testing."}
</p>
</div>
<div
ref={listRef}
className="flex-1 overflow-y-auto rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-4 min-h-[320px] max-h-[60vh]"
style={{ borderColor: "var(--hair)" }}
>
{messages.length === 0 ? (
<p className="muted text-[13px]">
Try &quot;add milk to the shopping list&quot; or &quot;what&apos;s on the calendar this
week?&quot;
</p>
) : (
<div className="grid gap-3">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg px-3 py-2 text-[13px] leading-relaxed whitespace-pre-wrap ${
message.role === "user"
? "ml-8 bg-[var(--shade)]"
: "mr-8 border-[0.5px] bg-[var(--card)]"
}`}
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
>
<div className="eyebrow mb-1">{message.role === "user" ? "You" : "Assistant"}</div>
{message.content}
</div>
))}
</div>
)}
</div>
{error ? <p className="text-[13px] text-destructive">{error}</p> : null}
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
sendMessage();
}}
>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask the assistant…"
disabled={isPending}
aria-label="Message"
/>
<Button type="submit" disabled={isPending || !input.trim()}>
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
Send
</Button>
</form>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "agent",
name: "Assistant",
nav: { href: "/assistant", label: "Assistant", icon: "message-circle" },
entities: [],
};
export default manifest;
+96
View File
@@ -0,0 +1,96 @@
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools";
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
const MAX_TOOL_ROUNDS = 8;
export type ClientChatMessage = {
role: "user" | "assistant";
content: string;
};
export type AgentToolCallSummary = {
name: string;
status: number;
};
export type AgentChatResult = {
message: ClientChatMessage;
toolCalls: AgentToolCallSummary[];
};
export async function runAgentChat(options: {
messages: ClientChatMessage[];
request: Request;
llm?: LlmClient;
executeTool?: ToolExecutor;
}): Promise<AgentChatResult> {
const llm = options.llm ?? createLlmClient();
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
const transcript: ChatMessage[] = [
{ role: "system", content: AGENT_SYSTEM_PROMPT },
...options.messages.map(
(message): ChatMessage => ({
role: message.role,
content: message.content,
}),
),
];
const toolCalls: AgentToolCallSummary[] = [];
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const completion = await llm.chatCompletion({
messages: transcript,
tools: AGENT_TOOLS,
});
const assistantMessage = completion.message;
transcript.push(assistantMessage);
if (!assistantMessage.tool_calls?.length) {
return {
message: {
role: "assistant",
content: assistantMessage.content?.trim() || "I couldn't generate a response.",
},
toolCalls,
};
}
for (const toolCall of assistantMessage.tool_calls) {
let result: string;
let status: number;
try {
result = await executeTool(toolCall.function.name, toolCall.function.arguments);
const parsed = JSON.parse(result) as { status?: number };
status = typeof parsed.status === "number" ? parsed.status : 200;
} catch (err) {
status = 500;
result = JSON.stringify({
status: 500,
body: { error: err instanceof Error ? err.message : "Tool execution failed" },
});
}
toolCalls.push({ name: toolCall.function.name, status });
transcript.push({
role: "tool",
tool_call_id: toolCall.id,
name: toolCall.function.name,
content: result,
});
}
}
return {
message: {
role: "assistant",
content: "I hit the tool-call limit for this request. Please try a simpler question.",
},
toolCalls,
};
}
+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 };
}
+139
View File
@@ -0,0 +1,139 @@
import type { AgentToolDefinition } from "@/lib/llm";
export const AGENT_TOOLS: AgentToolDefinition[] = [
{
type: "function",
function: {
name: "list_lists",
description: "List household lists. Optionally filter by type such as shopping or tasks.",
parameters: {
type: "object",
properties: {
type: { type: "string", description: "List type filter, e.g. shopping or tasks" },
},
},
},
},
{
type: "function",
function: {
name: "list_list_items",
description: "List items on a specific list by list id.",
parameters: {
type: "object",
properties: {
listId: { type: "string", description: "UUID of the list" },
},
required: ["listId"],
},
},
},
{
type: "function",
function: {
name: "add_list_item",
description:
"Add an item to a list. Provide listId, or listType (e.g. shopping) to use the first matching list.",
parameters: {
type: "object",
properties: {
listId: { type: "string", description: "UUID of the list" },
listType: { type: "string", description: "List type when listId is unknown" },
text: { type: "string", description: "Item text" },
qty: { type: "string", description: "Optional quantity" },
},
required: ["text"],
},
},
},
{
type: "function",
function: {
name: "list_calendars",
description: "List calendars visible to the household.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "list_events",
description: "List calendar events in a date range (ISO 8601 from/to).",
parameters: {
type: "object",
properties: {
from: { type: "string", description: "Range start (ISO 8601)" },
to: { type: "string", description: "Range end (ISO 8601)" },
calendarIds: {
type: "string",
description: 'Comma-separated calendar UUIDs, or "all"',
},
},
required: ["from", "to"],
},
},
},
{
type: "function",
function: {
name: "create_event",
description: "Create a calendar event.",
parameters: {
type: "object",
properties: {
calendarId: { type: "string" },
title: { type: "string" },
startAt: { type: "string", description: "ISO 8601 start" },
endAt: { type: "string", description: "ISO 8601 end" },
allDay: { type: "boolean" },
location: { type: "string" },
notes: { type: "string" },
},
required: ["calendarId", "title", "startAt", "endAt"],
},
},
},
{
type: "function",
function: {
name: "list_notes",
description: "List household notes.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "create_note",
description: "Create a note with title and optional body.",
parameters: {
type: "object",
properties: {
title: { type: "string" },
body: { type: "string" },
pinned: { type: "boolean" },
},
required: ["title"],
},
},
},
{
type: "function",
function: {
name: "list_journal_entries",
description: "List the current user's journal entries (most recent first).",
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "Max entries to return (default 20)" },
},
},
},
},
];
export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help Matt and his wife manage calendar events, shopping and task lists, notes, and personal journal entries.
Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly.
When adding shopping items, resolve the shopping list id via list_lists if needed. Use ISO 8601 datetimes for calendar tools.`;
+2
View File
@@ -6,6 +6,7 @@ import notesManifest from "./notes/manifest";
import gardenManifest from "./garden/manifest";
import bangsManifest from "./bangs/manifest";
import journalManifest from "./journal/manifest";
import agentManifest from "./agent/manifest";
registerModule(coreManifest);
registerModule(calendarManifest);
@@ -14,3 +15,4 @@ registerModule(notesManifest);
registerModule(gardenManifest);
registerModule(bangsManifest);
registerModule(journalManifest);
registerModule(agentManifest);