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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()} />;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 "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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user