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,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 "add milk to the shopping list" or "what's on the calendar this
|
||||
week?"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.`;
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user