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
+52
View File
@@ -0,0 +1,52 @@
import { z } from "zod";
import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth";
import { isLlmConfigured } from "@/lib/llm";
import { runAgentChat } from "@/modules/agent/server/run";
const chatInput = z.object({
messages: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
content: z.string().trim().min(1).max(8000),
}),
)
.min(1)
.max(40),
});
export async function POST(request: Request) {
const auth = await resolveApiAuth(request);
if (!auth) {
return apiError("Unauthorized", 401);
}
let body: unknown;
try {
body = await request.json();
} catch {
return apiError("Invalid JSON body", 400);
}
const parsed = chatInput.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
}
try {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
});
return apiJson({
...result,
configured: isLlmConfigured(),
provider: isLlmConfigured() ? "openai" : "mock",
});
} catch (err) {
const message = err instanceof Error ? err.message : "Agent request failed";
return apiError(message, 502);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { AssistantChat } from "@/modules/agent/components/assistant-chat";
import { isLlmConfigured } from "@/lib/llm";
export default function AssistantPage() {
return <AssistantChat configured={isLlmConfigured()} />;
}
+2
View File
@@ -21,6 +21,7 @@ import {
Lock,
Mail,
Menu,
MessageCircle,
MoreHorizontal,
Pencil,
Phone,
@@ -74,6 +75,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
sprout: Sprout,
mail: Mail,
menu: Menu,
"message-circle": MessageCircle,
more: MoreHorizontal,
x: X,
"chevron-left": ChevronLeft,
+30
View File
@@ -0,0 +1,30 @@
export type LlmProviderKind = "mock" | "openai";
export type LlmConfig = {
provider: LlmProviderKind;
baseUrl: string | null;
apiKey: string | null;
model: string;
};
export function getLlmConfig(): LlmConfig {
const providerEnv = process.env.LLM_PROVIDER?.trim().toLowerCase();
const baseUrl = process.env.LLM_BASE_URL?.trim() || null;
const apiKey = process.env.LLM_API_KEY?.trim() || null;
const model = process.env.LLM_MODEL?.trim() || "llama3.2";
if (providerEnv === "mock") {
return { provider: "mock", baseUrl: null, apiKey: null, model };
}
if (!baseUrl) {
return { provider: "mock", baseUrl: null, apiKey: null, model };
}
return { provider: "openai", baseUrl, apiKey, model };
}
export function isLlmConfigured(): boolean {
const config = getLlmConfig();
return config.provider === "openai" && Boolean(config.baseUrl);
}
+29
View File
@@ -0,0 +1,29 @@
import { createMockLlmClient } from "./mock";
import { createOpenAiCompatibleClient } from "./openai-compatible";
import { getLlmConfig } from "./config";
import type { LlmClient } from "./types";
export type {
AgentToolDefinition,
ChatCompletionRequest,
ChatCompletionResult,
ChatMessage,
LlmClient,
} from "./types";
export { getLlmConfig, isLlmConfigured } from "./config";
export { createMockLlmClient } from "./mock";
export function createLlmClient(override?: LlmClient): LlmClient {
if (override) return override;
const config = getLlmConfig();
if (config.provider === "mock" || !config.baseUrl) {
return createMockLlmClient();
}
return createOpenAiCompatibleClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
model: config.model,
});
}
+94
View File
@@ -0,0 +1,94 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
function lastUserText(messages: ChatCompletionRequest["messages"]): string {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role === "user" && message.content) {
return message.content.toLowerCase();
}
}
return "";
}
function hadToolResults(messages: ChatCompletionRequest["messages"]): boolean {
return messages.some((message) => message.role === "tool");
}
export function createMockLlmClient(): LlmClient {
return {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
const userText = lastUserText(request.messages);
if (hadToolResults(request.messages)) {
return {
message: {
role: "assistant",
content: "Done — I updated your household data using the API.",
},
finishReason: "stop",
};
}
if (userText.includes("milk") && request.tools?.length) {
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "mock_call_add_item",
type: "function",
function: {
name: "add_list_item",
arguments: JSON.stringify({
listType: "shopping",
text: "milk",
}),
},
},
],
},
finishReason: "tool_calls",
};
}
if (userText.includes("calendar") && request.tools?.length) {
const now = new Date();
const from = new Date(now);
from.setDate(from.getDate() - 1);
const to = new Date(now);
to.setDate(to.getDate() + 7);
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "mock_call_list_events",
type: "function",
function: {
name: "list_events",
arguments: JSON.stringify({
from: from.toISOString(),
to: to.toISOString(),
}),
},
},
],
},
finishReason: "tool_calls",
};
}
return {
message: {
role: "assistant",
content:
"I'm the famapp assistant (mock provider). Ask me to add items or check the calendar.",
},
finishReason: "stop",
};
},
};
}
+74
View File
@@ -0,0 +1,74 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
type OpenAiMessage = {
role: string;
content: string | null;
tool_calls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
tool_call_id?: string;
name?: string;
};
export function createOpenAiCompatibleClient(options: {
baseUrl: string;
apiKey: string | null;
model: string;
}): LlmClient {
const completionsUrl = `${options.baseUrl.replace(/\/$/, "")}/chat/completions`;
return {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.apiKey) {
headers.Authorization = `Bearer ${options.apiKey}`;
}
const body = {
model: options.model,
messages: request.messages as OpenAiMessage[],
tools: request.tools,
tool_choice: request.tools?.length ? "auto" : undefined,
};
const response = await fetch(completionsUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`LLM request failed (${response.status}): ${detail.slice(0, 400)}`);
}
const payload = (await response.json()) as {
choices?: Array<{
finish_reason?: string | null;
message?: OpenAiMessage;
}>;
};
const choice = payload.choices?.[0];
const message = choice?.message;
if (!message) {
throw new Error("LLM response missing message");
}
return {
message: {
role: message.role as ChatCompletionResult["message"]["role"],
content: message.content,
tool_calls: message.tool_calls,
tool_call_id: message.tool_call_id,
name: message.name,
},
finishReason: choice.finish_reason ?? null,
};
},
};
}
+41
View File
@@ -0,0 +1,41 @@
export type ChatRole = "system" | "user" | "assistant" | "tool";
export type ChatToolCall = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
export type ChatMessage = {
role: ChatRole;
content: string | null;
tool_calls?: ChatToolCall[];
tool_call_id?: string;
name?: string;
};
export type AgentToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
export type ChatCompletionRequest = {
messages: ChatMessage[];
tools?: AgentToolDefinition[];
};
export type ChatCompletionResult = {
message: ChatMessage;
finishReason: string | null;
};
export interface LlmClient {
chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult>;
}
@@ -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);