From 4a924a41072a43484c009757a1644cd42309fc0e Mon Sep 17 00:00:00 2001 From: ginnoir Date: Sat, 4 Jul 2026 19:46:09 -0500 Subject: [PATCH] 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. --- .env.example | 7 + STATUS.md | 6 +- docs/tasks/88-llm-agent-chat.md | 8 +- src/app/api/agent/chat/route.ts | 52 +++++++ src/app/assistant/page.tsx | 6 + src/components/nav-icon.tsx | 2 + src/lib/llm/config.ts | 30 ++++ src/lib/llm/index.ts | 29 ++++ src/lib/llm/mock.ts | 94 ++++++++++++ src/lib/llm/openai-compatible.ts | 74 +++++++++ src/lib/llm/types.ts | 41 +++++ .../agent/components/assistant-chat.tsx | 134 ++++++++++++++++ src/modules/agent/manifest.tsx | 10 ++ src/modules/agent/server/run.ts | 96 ++++++++++++ src/modules/agent/tool-executor.ts | 145 ++++++++++++++++++ src/modules/agent/tools.ts | 139 +++++++++++++++++ src/modules/index.ts | 2 + tests/e2e/assistant.spec.ts | 13 ++ tests/unit/agent-chat.test.ts | 74 +++++++++ 19 files changed, 956 insertions(+), 6 deletions(-) create mode 100644 src/app/api/agent/chat/route.ts create mode 100644 src/app/assistant/page.tsx create mode 100644 src/lib/llm/config.ts create mode 100644 src/lib/llm/index.ts create mode 100644 src/lib/llm/mock.ts create mode 100644 src/lib/llm/openai-compatible.ts create mode 100644 src/lib/llm/types.ts create mode 100644 src/modules/agent/components/assistant-chat.tsx create mode 100644 src/modules/agent/manifest.tsx create mode 100644 src/modules/agent/server/run.ts create mode 100644 src/modules/agent/tool-executor.ts create mode 100644 src/modules/agent/tools.ts create mode 100644 tests/e2e/assistant.spec.ts create mode 100644 tests/unit/agent-chat.test.ts diff --git a/.env.example b/.env.example index ff4b685..a577999 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,10 @@ MINIO_BUCKET=garden # OpenPlantBook plant species API (https://open.plantbook.io — free account required) OPENPLANTBOOK_CLIENT_ID= OPENPLANTBOOK_CLIENT_SECRET= + +# LLM assistant (OpenAI-compatible — Ollama, vLLM, LiteLLM, etc.) +# Leave LLM_BASE_URL unset to use the built-in mock provider (CI / local without a model). +LLM_PROVIDER=openai +LLM_BASE_URL= +LLM_API_KEY= +LLM_MODEL=llama3.2 diff --git a/STATUS.md b/STATUS.md index 4077262..306a246 100644 --- a/STATUS.md +++ b/STATUS.md @@ -19,6 +19,8 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **86 — Journal module** (ADR 0005 accepted `8e2ddd6`). Per-user `journal_entries` schema + migration `0020_journal_entries.sql`; mood catalog (multi-select), optional stress/pills, rich-text body; index with calendar dots, entry editor, Recharts mood tracker, insights (streak/trends/correlations); `/api/v1/journal/entries` CRUD with bearer token auth; OpenAPI updated. Unit tests: `journal-analytics.test.ts`. E2E: `tests/e2e/journal.spec.ts`. Run `pnpm db:migrate` for migration `0020`. +- **88 — LLM agent chat** (ADR 0006). `/assistant` chat UI; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls (lists, calendar, notes, journal); `POST /api/agent/chat`. Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`. + - **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green. - **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility. - **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes. @@ -65,11 +67,11 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des 2. ~~API foundation: task 87 (+ ADR 0006)~~ — done 3. ~~Shared rich-text + notes overhaul: task 85 (+ ADR 0004)~~ — done 4. ~~Journal: task 86 (+ ADR 0005), including journal API endpoints~~ — done -5. LLM agent chat: task 88 +5. ~~LLM agent chat: task 88~~ — done P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 7–9, 11–12, 15–19. -**How to resume:** Read AGENTS.md / CLAUDE.md / STATUS.md, open the next unchecked task in `docs/tasks/80`–`88`, stop at acceptance criteria. +**How to resume:** Phase 9 P1 batch complete. Next work is P2/P3 Gitea backlog or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`). ## Development login/testing notes diff --git a/docs/tasks/88-llm-agent-chat.md b/docs/tasks/88-llm-agent-chat.md index 53549ae..b0b2b9c 100644 --- a/docs/tasks/88-llm-agent-chat.md +++ b/docs/tasks/88-llm-agent-chat.md @@ -28,10 +28,10 @@ Users should say "add milk to the shopping list" or "what's on the calendar Frid ## Acceptance criteria -- [ ] Chat UI sends prompts and shows responses. -- [ ] Tools call the API successfully in dev against the homelab endpoint. -- [ ] CI uses mock provider only. -- [ ] ADR 0006 records provider and MCP decisions. +- [x] Chat UI sends prompts and shows responses. +- [x] Tools call the API successfully in dev against the homelab endpoint. +- [x] CI uses mock provider only. +- [x] ADR 0006 records provider and MCP decisions. ## Notes diff --git a/src/app/api/agent/chat/route.ts b/src/app/api/agent/chat/route.ts new file mode 100644 index 0000000..6bd41c2 --- /dev/null +++ b/src/app/api/agent/chat/route.ts @@ -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); + } +} diff --git a/src/app/assistant/page.tsx b/src/app/assistant/page.tsx new file mode 100644 index 0000000..4a569a5 --- /dev/null +++ b/src/app/assistant/page.tsx @@ -0,0 +1,6 @@ +import { AssistantChat } from "@/modules/agent/components/assistant-chat"; +import { isLlmConfigured } from "@/lib/llm"; + +export default function AssistantPage() { + return ; +} diff --git a/src/components/nav-icon.tsx b/src/components/nav-icon.tsx index 8d7c7e5..b332ed9 100644 --- a/src/components/nav-icon.tsx +++ b/src/components/nav-icon.tsx @@ -21,6 +21,7 @@ import { Lock, Mail, Menu, + MessageCircle, MoreHorizontal, Pencil, Phone, @@ -74,6 +75,7 @@ const ICONS: Record> = { sprout: Sprout, mail: Mail, menu: Menu, + "message-circle": MessageCircle, more: MoreHorizontal, x: X, "chevron-left": ChevronLeft, diff --git a/src/lib/llm/config.ts b/src/lib/llm/config.ts new file mode 100644 index 0000000..70fac39 --- /dev/null +++ b/src/lib/llm/config.ts @@ -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); +} diff --git a/src/lib/llm/index.ts b/src/lib/llm/index.ts new file mode 100644 index 0000000..64d7506 --- /dev/null +++ b/src/lib/llm/index.ts @@ -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, + }); +} diff --git a/src/lib/llm/mock.ts b/src/lib/llm/mock.ts new file mode 100644 index 0000000..beb104f --- /dev/null +++ b/src/lib/llm/mock.ts @@ -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 { + 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", + }; + }, + }; +} diff --git a/src/lib/llm/openai-compatible.ts b/src/lib/llm/openai-compatible.ts new file mode 100644 index 0000000..cb95f86 --- /dev/null +++ b/src/lib/llm/openai-compatible.ts @@ -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 { + const headers: Record = { + "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, + }; + }, + }; +} diff --git a/src/lib/llm/types.ts b/src/lib/llm/types.ts new file mode 100644 index 0000000..d01e893 --- /dev/null +++ b/src/lib/llm/types.ts @@ -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; + }; +}; + +export type ChatCompletionRequest = { + messages: ChatMessage[]; + tools?: AgentToolDefinition[]; +}; + +export type ChatCompletionResult = { + message: ChatMessage; + finishReason: string | null; +}; + +export interface LlmClient { + chatCompletion(request: ChatCompletionRequest): Promise; +} diff --git a/src/modules/agent/components/assistant-chat.tsx b/src/modules/agent/components/assistant-chat.tsx new file mode 100644 index 0000000..f83d251 --- /dev/null +++ b/src/modules/agent/components/assistant-chat.tsx @@ -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([]); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + const listRef = useRef(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 ( +
+
+

Assistant

+

+ {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."} +

+
+ +
+ {messages.length === 0 ? ( +

+ Try "add milk to the shopping list" or "what's on the calendar this + week?" +

+ ) : ( +
+ {messages.map((message, index) => ( +
+
{message.role === "user" ? "You" : "Assistant"}
+ {message.content} +
+ ))} +
+ )} +
+ + {error ?

{error}

: null} + +
{ + event.preventDefault(); + sendMessage(); + }} + > + setInput(event.target.value)} + placeholder="Ask the assistant…" + disabled={isPending} + aria-label="Message" + /> + +
+
+ ); +} diff --git a/src/modules/agent/manifest.tsx b/src/modules/agent/manifest.tsx new file mode 100644 index 0000000..88fef9a --- /dev/null +++ b/src/modules/agent/manifest.tsx @@ -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; diff --git a/src/modules/agent/server/run.ts b/src/modules/agent/server/run.ts new file mode 100644 index 0000000..e457127 --- /dev/null +++ b/src/modules/agent/server/run.ts @@ -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 { + 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, + }; +} diff --git a/src/modules/agent/tool-executor.ts b/src/modules/agent/tool-executor.ts new file mode 100644 index 0000000..a4a734e --- /dev/null +++ b/src/modules/agent/tool-executor.ts @@ -0,0 +1,145 @@ +type ApiCallResult = { + status: number; + body: unknown; +}; + +export type ToolExecutor = (name: string, argsJson: string) => Promise; + +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 { + 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; +} + +async function dispatchTool( + name: string, + args: Record, + request: Request, + origin: string, +): Promise { + 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 = { 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, 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, +): Promise { + 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 }; +} diff --git a/src/modules/agent/tools.ts b/src/modules/agent/tools.ts new file mode 100644 index 0000000..f0f4db9 --- /dev/null +++ b/src/modules/agent/tools.ts @@ -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.`; diff --git a/src/modules/index.ts b/src/modules/index.ts index 293ac8d..50b7486 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -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); diff --git a/tests/e2e/assistant.spec.ts b/tests/e2e/assistant.spec.ts new file mode 100644 index 0000000..9bf6020 --- /dev/null +++ b/tests/e2e/assistant.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test("assistant chat smoke", async ({ page }) => { + await page.goto("/assistant"); + await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible(); + + await page.getByLabel("Message").fill("hello assistant"); + await page.getByRole("button", { name: "Send" }).click(); + + await expect(page.getByText("You")).toBeVisible(); + await expect(page.getByText("hello assistant")).toBeVisible(); + await expect(page.getByText("Assistant", { exact: true }).nth(1)).toBeVisible(); +}); diff --git a/tests/unit/agent-chat.test.ts b/tests/unit/agent-chat.test.ts new file mode 100644 index 0000000..fab9371 --- /dev/null +++ b/tests/unit/agent-chat.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createMockLlmClient } from "../../src/lib/llm/mock"; +import { getLlmConfig } from "../../src/lib/llm/config"; +import { runAgentChat } from "../../src/modules/agent/server/run"; + +describe("mock llm provider", () => { + it("returns a plain assistant message by default", async () => { + const client = createMockLlmClient(); + const result = await client.chatCompletion({ + messages: [{ role: "user", content: "hello" }], + tools: [], + }); + assert.equal(result.message.role, "assistant"); + assert.ok(result.message.content?.includes("mock provider")); + }); + + it("requests add_list_item when the user mentions milk", async () => { + const client = createMockLlmClient(); + const result = await client.chatCompletion({ + messages: [{ role: "user", content: "add milk to shopping list" }], + tools: [ + { + type: "function", + function: { + name: "add_list_item", + description: "add", + parameters: { type: "object", properties: {} }, + }, + }, + ], + }); + assert.equal(result.message.tool_calls?.[0]?.function.name, "add_list_item"); + }); +}); + +describe("getLlmConfig", () => { + it("uses mock provider when base url is unset", () => { + const original = process.env.LLM_BASE_URL; + const originalProvider = process.env.LLM_PROVIDER; + delete process.env.LLM_BASE_URL; + delete process.env.LLM_PROVIDER; + + const config = getLlmConfig(); + assert.equal(config.provider, "mock"); + + if (original === undefined) delete process.env.LLM_BASE_URL; + else process.env.LLM_BASE_URL = original; + if (originalProvider === undefined) delete process.env.LLM_PROVIDER; + else process.env.LLM_PROVIDER = originalProvider; + }); +}); + +describe("runAgentChat", () => { + it("executes tool calls and returns a final assistant message", async () => { + const executed: string[] = []; + const result = await runAgentChat({ + messages: [{ role: "user", content: "add milk to the shopping list" }], + request: new Request("http://localhost:3000/api/agent/chat"), + llm: createMockLlmClient(), + executeTool: async (name) => { + executed.push(name); + return JSON.stringify({ status: 201, body: { id: "item-1", text: "milk" } }); + }, + }); + + assert.deepEqual(executed, ["add_list_item"]); + assert.equal(result.message.role, "assistant"); + assert.ok(result.message.content.length > 0); + assert.equal(result.toolCalls.length, 1); + assert.equal(result.toolCalls[0]?.name, "add_list_item"); + assert.equal(result.toolCalls[0]?.status, 201); + }); +});