feat: journal dashboard widgets, agent polish, and edit-mode live previews
Journal dashboard widgets and quick-add; rich-text quick-add dialogs. Dashboard draft sync for live edit previews; assistant bubble + API tools. Journal UX: stress slider, mood grid, query cap fix.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
export type AssistantChatMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
const STORAGE_VERSION = "v1";
|
||||
const MAX_MESSAGES = 40;
|
||||
|
||||
function storageKey(userId: string) {
|
||||
return `assistant-chat:${STORAGE_VERSION}:${userId}`;
|
||||
}
|
||||
|
||||
function isValidMessage(value: unknown): value is AssistantChatMessage {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
return (
|
||||
(row.role === "user" || row.role === "assistant") &&
|
||||
typeof row.content === "string" &&
|
||||
row.content.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function loadAssistantChat(userId: string): AssistantChatMessage[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(userId));
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isValidMessage).slice(-MAX_MESSAGES);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAssistantChat(userId: string, messages: AssistantChatMessage[]): void {
|
||||
try {
|
||||
const trimmed = messages.slice(-MAX_MESSAGES);
|
||||
if (trimmed.length === 0) {
|
||||
localStorage.removeItem(storageKey(userId));
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(storageKey(userId), JSON.stringify(trimmed));
|
||||
} catch {
|
||||
// Private browsing, quota exceeded, or disabled storage.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAssistantChat(userId: string): void {
|
||||
try {
|
||||
localStorage.removeItem(storageKey(userId));
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AgentProgressEvent } from "./server/progress";
|
||||
|
||||
type DoneEvent = Extract<AgentProgressEvent, { type: "done" }>;
|
||||
|
||||
export async function consumeAgentChatStream(
|
||||
response: Response,
|
||||
onEvent: (event: AgentProgressEvent) => void,
|
||||
): Promise<DoneEvent> {
|
||||
if (!response.ok) {
|
||||
let message = "Assistant request failed";
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (payload.error) message = payload.error;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Assistant returned an empty stream");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let doneEvent: DoneEvent | null = null;
|
||||
|
||||
const handleEvent = (event: AgentProgressEvent) => {
|
||||
onEvent(event);
|
||||
if (event.type === "error") throw new Error(event.message);
|
||||
if (event.type === "done") doneEvent = event;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let boundary = buffer.indexOf("\n\n");
|
||||
while (boundary !== -1) {
|
||||
const chunk = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
parseSseChunk(chunk, handleEvent);
|
||||
boundary = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
parseSseChunk(buffer, handleEvent);
|
||||
}
|
||||
|
||||
if (!doneEvent) {
|
||||
throw new Error("Assistant stream ended without a final response");
|
||||
}
|
||||
|
||||
return doneEvent;
|
||||
}
|
||||
|
||||
function parseSseChunk(chunk: string, onEvent: (event: AgentProgressEvent) => void) {
|
||||
for (const line of chunk.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const payload = line.slice("data: ".length);
|
||||
if (!payload) continue;
|
||||
onEvent(JSON.parse(payload) as AgentProgressEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MessageCircle, X } from "lucide-react";
|
||||
import { AssistantPanel } from "./assistant-panel";
|
||||
|
||||
type Props = {
|
||||
configured: boolean;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export function AssistantBubble({ configured, userId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="assistant-bubble" data-open={open ? "true" : "false"}>
|
||||
{open ? (
|
||||
<div
|
||||
className="assistant-bubble-panel"
|
||||
role="dialog"
|
||||
aria-label="Assistant"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div className="assistant-bubble-header">
|
||||
<div>
|
||||
<div className="serif text-[15px] font-medium tracking-tight">Assistant</div>
|
||||
<div className="muted text-[11px]">Household helper</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-bubble-close"
|
||||
aria-label="Close assistant"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<AssistantPanel key={userId} configured={configured} userId={userId} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-bubble-trigger"
|
||||
aria-label={open ? "Close assistant" : "Open assistant"}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
"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,194 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { consumeAgentChatStream } from "../assistant-chat-stream";
|
||||
import {
|
||||
clearAssistantChat,
|
||||
loadAssistantChat,
|
||||
saveAssistantChat,
|
||||
type AssistantChatMessage,
|
||||
} from "../assistant-chat-storage";
|
||||
|
||||
type Props = {
|
||||
configured: boolean;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export function AssistantPanel({ configured, userId }: Props) {
|
||||
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [activityLabel, setActivityLabel] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
saveAssistantChat(userId, messages);
|
||||
}, [messages, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
const node = listRef.current;
|
||||
if (node) node.scrollTop = node.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
abortRef.current?.abort();
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setActivityLabel(null);
|
||||
setIsPending(false);
|
||||
clearAssistantChat(userId);
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = input.trim();
|
||||
if (!text || isPending) return;
|
||||
|
||||
const nextMessages: AssistantChatMessage[] = [...messages, { role: "user", content: text }];
|
||||
setInput("");
|
||||
setError(null);
|
||||
setMessages(nextMessages);
|
||||
setIsPending(true);
|
||||
setActivityLabel("Understanding your request…");
|
||||
scrollToBottom();
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/agent/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: nextMessages, stream: true }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const result = await consumeAgentChatStream(response, (event) => {
|
||||
if (event.type === "thinking" || event.type === "tool" || event.type === "responding") {
|
||||
setActivityLabel(event.label);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.message.content) {
|
||||
throw new Error("Assistant returned an empty response");
|
||||
}
|
||||
|
||||
setMessages((current) => [...current, result.message]);
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
setActivityLabel(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
const showEmptyState = messages.length === 0 && !isPending;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="muted min-w-0 text-[12px] leading-relaxed">
|
||||
{configured
|
||||
? "Ask me to update lists, calendar, notes, or journal."
|
||||
: "Mock provider active — set LLM_BASE_URL for your homelab model."}
|
||||
</p>
|
||||
{messages.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearChat}
|
||||
disabled={isPending}
|
||||
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-[var(--r-md)] border-[0.5px] bg-[var(--shade)] p-3"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
{showEmptyState ? (
|
||||
<p className="muted text-[12px]">
|
||||
Try "add milk to the shopping list" or "what's on the calendar this
|
||||
week?"
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={`${message.role}-${index}`}
|
||||
className={`rounded-lg px-2.5 py-2 text-[12.5px] leading-relaxed whitespace-pre-wrap ${
|
||||
message.role === "user"
|
||||
? "ml-6 bg-[var(--card)]"
|
||||
: "mr-6 border-[0.5px] bg-[var(--card)]"
|
||||
}`}
|
||||
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
|
||||
>
|
||||
<div className="eyebrow mb-0.5 text-[10px]">
|
||||
{message.role === "user" ? "You" : "Assistant"}
|
||||
</div>
|
||||
{message.content}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isPending ? (
|
||||
<div
|
||||
className="mr-6 rounded-lg border-[0.5px] bg-[var(--card)] px-2.5 py-2"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="eyebrow mb-1 text-[10px]">Assistant</div>
|
||||
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
<span>{activityLabel ?? "Working…"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-[12px] 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="Assistant message"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={isPending || !input.trim()} aria-label="Send">
|
||||
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type { ModuleManifest } from "../_core/module";
|
||||
const manifest: ModuleManifest = {
|
||||
id: "agent",
|
||||
name: "Assistant",
|
||||
nav: { href: "/assistant", label: "Assistant", icon: "message-circle" },
|
||||
entities: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type AgentProgressEvent =
|
||||
| { type: "thinking"; label: string; round: number }
|
||||
| { type: "tool"; name: string; label: string }
|
||||
| { type: "responding"; label: string }
|
||||
| {
|
||||
type: "done";
|
||||
message: { role: "assistant"; content: string };
|
||||
toolCalls: { name: string; status: number }[];
|
||||
}
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export function thinkingLabel(round: number): string {
|
||||
if (round === 0) return "Understanding your request…";
|
||||
return "Reviewing what I found…";
|
||||
}
|
||||
|
||||
export function encodeSseEvent(event: AgentProgressEvent): string {
|
||||
return `data: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
|
||||
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools";
|
||||
import { describeToolActivity } from "../tool-labels";
|
||||
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
|
||||
import { thinkingLabel, type AgentProgressEvent } from "./progress";
|
||||
|
||||
const MAX_TOOL_ROUNDS = 8;
|
||||
|
||||
@@ -19,14 +21,18 @@ export type AgentChatResult = {
|
||||
toolCalls: AgentToolCallSummary[];
|
||||
};
|
||||
|
||||
export type AgentProgressHandler = (event: AgentProgressEvent) => void;
|
||||
|
||||
export async function runAgentChat(options: {
|
||||
messages: ClientChatMessage[];
|
||||
request: Request;
|
||||
llm?: LlmClient;
|
||||
executeTool?: ToolExecutor;
|
||||
onProgress?: AgentProgressHandler;
|
||||
}): Promise<AgentChatResult> {
|
||||
const llm = options.llm ?? createLlmClient();
|
||||
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
|
||||
const onProgress = options.onProgress;
|
||||
|
||||
const transcript: ChatMessage[] = [
|
||||
{ role: "system", content: AGENT_SYSTEM_PROMPT },
|
||||
@@ -41,6 +47,8 @@ export async function runAgentChat(options: {
|
||||
const toolCalls: AgentToolCallSummary[] = [];
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
onProgress?.({ type: "thinking", label: thinkingLabel(round), round });
|
||||
|
||||
const completion = await llm.chatCompletion({
|
||||
messages: transcript,
|
||||
tools: AGENT_TOOLS,
|
||||
@@ -50,6 +58,7 @@ export async function runAgentChat(options: {
|
||||
transcript.push(assistantMessage);
|
||||
|
||||
if (!assistantMessage.tool_calls?.length) {
|
||||
onProgress?.({ type: "responding", label: "Writing a reply…" });
|
||||
return {
|
||||
message: {
|
||||
role: "assistant",
|
||||
@@ -60,6 +69,9 @@ export async function runAgentChat(options: {
|
||||
}
|
||||
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
const label = describeToolActivity(toolCall.function.name, toolCall.function.arguments);
|
||||
onProgress?.({ type: "tool", name: toolCall.function.name, label });
|
||||
|
||||
let result: string;
|
||||
let status: number;
|
||||
|
||||
@@ -86,6 +98,7 @@ export async function runAgentChat(options: {
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.({ type: "responding", label: "Wrapping up…" });
|
||||
return {
|
||||
message: {
|
||||
role: "assistant",
|
||||
|
||||
@@ -66,6 +66,38 @@ async function dispatchTool(
|
||||
if (typeof args.qty === "string") body.qty = args.qty;
|
||||
return callApi(request, origin, "POST", `/api/v1/lists/${listId}/items`, body);
|
||||
}
|
||||
case "update_list_item": {
|
||||
const listId = requireString(args, "listId");
|
||||
const itemId = requireString(args, "itemId");
|
||||
const body: Record<string, unknown> = {};
|
||||
if (typeof args.done === "boolean") body.done = args.done;
|
||||
if (typeof args.text === "string") body.text = args.text;
|
||||
if (typeof args.qty === "string") body.qty = args.qty;
|
||||
return callApi(request, origin, "PATCH", `/api/v1/lists/${listId}/items/${itemId}`, body);
|
||||
}
|
||||
case "delete_list_item": {
|
||||
const listId = requireString(args, "listId");
|
||||
const itemId = requireString(args, "itemId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/lists/${listId}/items/${itemId}`);
|
||||
}
|
||||
case "create_list": {
|
||||
const body = {
|
||||
type: requireString(args, "type"),
|
||||
name: requireString(args, "name"),
|
||||
};
|
||||
return callApi(request, origin, "POST", "/api/v1/lists", body);
|
||||
}
|
||||
case "update_list": {
|
||||
const listId = requireString(args, "listId");
|
||||
const body: Record<string, unknown> = {};
|
||||
if (typeof args.name === "string") body.name = args.name;
|
||||
if (typeof args.archived === "boolean") body.archived = args.archived;
|
||||
return callApi(request, origin, "PATCH", `/api/v1/lists/${listId}`, body);
|
||||
}
|
||||
case "delete_list": {
|
||||
const listId = requireString(args, "listId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/lists/${listId}`);
|
||||
}
|
||||
case "list_calendars":
|
||||
return callApi(request, origin, "GET", "/api/v1/calendars");
|
||||
case "list_events": {
|
||||
@@ -79,29 +111,265 @@ async function dispatchTool(
|
||||
return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`);
|
||||
}
|
||||
case "create_event": {
|
||||
const body = {
|
||||
const body: Record<string, unknown> = {
|
||||
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,
|
||||
};
|
||||
if (typeof args.location === "string") body.location = args.location;
|
||||
if (typeof args.notes === "string") body.notes = args.notes;
|
||||
if (typeof args.remindMinutesBefore === "number") {
|
||||
body.remindMinutesBefore = args.remindMinutesBefore;
|
||||
}
|
||||
return callApi(request, origin, "POST", "/api/v1/events", body);
|
||||
}
|
||||
case "update_event": {
|
||||
const eventId = requireString(args, "eventId");
|
||||
const body: Record<string, unknown> = {};
|
||||
if (typeof args.title === "string") body.title = args.title;
|
||||
if (typeof args.startAt === "string") body.startAt = args.startAt;
|
||||
if (typeof args.endAt === "string") body.endAt = args.endAt;
|
||||
if (typeof args.allDay === "boolean") body.allDay = args.allDay;
|
||||
if (typeof args.location === "string") body.location = args.location;
|
||||
if (typeof args.notes === "string") body.notes = args.notes;
|
||||
if (typeof args.remindMinutesBefore === "number") {
|
||||
body.remindMinutesBefore = args.remindMinutesBefore;
|
||||
}
|
||||
return callApi(request, origin, "PATCH", `/api/v1/events/${eventId}`, body);
|
||||
}
|
||||
case "delete_event": {
|
||||
const eventId = requireString(args, "eventId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/events/${eventId}`);
|
||||
}
|
||||
case "create_calendar": {
|
||||
const body: Record<string, unknown> = {
|
||||
name: requireString(args, "name"),
|
||||
};
|
||||
if (typeof args.color === "string") body.color = args.color;
|
||||
if (args.visibility === "private" || args.visibility === "household") {
|
||||
body.visibility = args.visibility;
|
||||
}
|
||||
return callApi(request, origin, "POST", "/api/v1/calendars", body);
|
||||
}
|
||||
case "list_notes":
|
||||
return callApi(request, origin, "GET", "/api/v1/notes");
|
||||
case "create_note": {
|
||||
const body = {
|
||||
const body: Record<string, unknown> = {
|
||||
title: requireString(args, "title"),
|
||||
body: typeof args.body === "string" ? args.body : "",
|
||||
pinned: typeof args.pinned === "boolean" ? args.pinned : false,
|
||||
};
|
||||
if (typeof args.remindAt === "string") body.remindAt = args.remindAt;
|
||||
return callApi(request, origin, "POST", "/api/v1/notes", body);
|
||||
}
|
||||
case "list_journal_entries":
|
||||
return callApi(request, origin, "GET", "/api/v1/journal/entries");
|
||||
case "update_note": {
|
||||
const noteId = requireString(args, "noteId");
|
||||
const body: Record<string, unknown> = {};
|
||||
if (typeof args.title === "string") body.title = args.title;
|
||||
if (typeof args.body === "string") body.body = args.body;
|
||||
if (typeof args.pinned === "boolean") body.pinned = args.pinned;
|
||||
if (typeof args.remindAt === "string") body.remindAt = args.remindAt;
|
||||
if (args.remindAt === null) body.remindAt = null;
|
||||
return callApi(request, origin, "PATCH", `/api/v1/notes/${noteId}`, body);
|
||||
}
|
||||
case "delete_note": {
|
||||
const noteId = requireString(args, "noteId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/notes/${noteId}`);
|
||||
}
|
||||
case "list_journal_entries": {
|
||||
const limit = typeof args.limit === "number" ? String(args.limit) : undefined;
|
||||
const qs = limit ? `?limit=${encodeURIComponent(limit)}` : "";
|
||||
return callApi(request, origin, "GET", `/api/v1/journal/entries${qs}`);
|
||||
}
|
||||
case "create_journal_entry": {
|
||||
const body: Record<string, unknown> = {
|
||||
recordedAt: requireString(args, "recordedAt"),
|
||||
};
|
||||
if (typeof args.title === "string") body.title = args.title;
|
||||
if (typeof args.body === "string") body.body = args.body;
|
||||
if (Array.isArray(args.moods)) body.moods = args.moods;
|
||||
if (typeof args.stress === "number") body.stress = args.stress;
|
||||
if (typeof args.pillsTaken === "boolean") body.pillsTaken = args.pillsTaken;
|
||||
return callApi(request, origin, "POST", "/api/v1/journal/entries", body);
|
||||
}
|
||||
case "update_journal_entry": {
|
||||
const entryId = requireString(args, "entryId");
|
||||
const body: Record<string, unknown> = {};
|
||||
if (typeof args.recordedAt === "string") body.recordedAt = args.recordedAt;
|
||||
if (typeof args.title === "string") body.title = args.title;
|
||||
if (typeof args.body === "string") body.body = args.body;
|
||||
if (Array.isArray(args.moods)) body.moods = args.moods;
|
||||
if (typeof args.stress === "number") body.stress = args.stress;
|
||||
if (typeof args.pillsTaken === "boolean") body.pillsTaken = args.pillsTaken;
|
||||
return callApi(request, origin, "PATCH", `/api/v1/journal/entries/${entryId}`, body);
|
||||
}
|
||||
case "delete_journal_entry": {
|
||||
const entryId = requireString(args, "entryId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/journal/entries/${entryId}`);
|
||||
}
|
||||
case "list_bangs": {
|
||||
const limit = typeof args.limit === "number" ? String(args.limit) : undefined;
|
||||
const qs = limit ? `?limit=${encodeURIComponent(limit)}` : "";
|
||||
return callApi(request, origin, "GET", `/api/v1/bangs${qs}`);
|
||||
}
|
||||
case "add_bang": {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (typeof args.occurredOn === "string") body.occurredOn = args.occurredOn;
|
||||
return callApi(request, origin, "POST", "/api/v1/bangs", body);
|
||||
}
|
||||
case "update_bang": {
|
||||
const bangId = requireString(args, "bangId");
|
||||
const body = { occurredOn: requireString(args, "occurredOn") };
|
||||
return callApi(request, origin, "PATCH", `/api/v1/bangs/${bangId}`, body);
|
||||
}
|
||||
case "delete_bang": {
|
||||
const bangId = requireString(args, "bangId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/bangs/${bangId}`);
|
||||
}
|
||||
case "list_garden_containers":
|
||||
return callApi(request, origin, "GET", "/api/v1/garden/containers");
|
||||
case "list_garden_plants": {
|
||||
const containerId = typeof args.containerId === "string" ? args.containerId : undefined;
|
||||
const qs = containerId ? `?containerId=${encodeURIComponent(containerId)}` : "";
|
||||
return callApi(request, origin, "GET", `/api/v1/garden/plants${qs}`);
|
||||
}
|
||||
case "get_garden_plant": {
|
||||
const plantId = requireString(args, "plantId");
|
||||
return callApi(request, origin, "GET", `/api/v1/garden/plants/${plantId}`);
|
||||
}
|
||||
case "create_garden_container": {
|
||||
const body: Record<string, unknown> = { name: requireString(args, "name") };
|
||||
if (typeof args.type === "string") body.type = args.type;
|
||||
if (typeof args.locationNotes === "string") body.locationNotes = args.locationNotes;
|
||||
return callApi(request, origin, "POST", "/api/v1/garden/containers", body);
|
||||
}
|
||||
case "update_garden_container": {
|
||||
const containerId = requireString(args, "containerId");
|
||||
const body: Record<string, unknown> = {};
|
||||
if (typeof args.name === "string") body.name = args.name;
|
||||
if (typeof args.type === "string") body.type = args.type;
|
||||
if (typeof args.locationNotes === "string") body.locationNotes = args.locationNotes;
|
||||
return callApi(request, origin, "PATCH", `/api/v1/garden/containers/${containerId}`, body);
|
||||
}
|
||||
case "delete_garden_container": {
|
||||
const containerId = requireString(args, "containerId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/garden/containers/${containerId}`);
|
||||
}
|
||||
case "create_garden_plant": {
|
||||
const body: Record<string, unknown> = { name: requireString(args, "name") };
|
||||
if (typeof args.category === "string") body.category = args.category;
|
||||
if (typeof args.containerId === "string") body.containerId = args.containerId;
|
||||
if (typeof args.healthStatus === "string") body.healthStatus = args.healthStatus;
|
||||
if (typeof args.notes === "string") body.notes = args.notes;
|
||||
return callApi(request, origin, "POST", "/api/v1/garden/plants", body);
|
||||
}
|
||||
case "update_garden_plant": {
|
||||
const plantId = requireString(args, "plantId");
|
||||
const body: Record<string, unknown> = {};
|
||||
if (typeof args.name === "string") body.name = args.name;
|
||||
if (typeof args.category === "string") body.category = args.category;
|
||||
if (typeof args.containerId === "string") body.containerId = args.containerId;
|
||||
if (typeof args.healthStatus === "string") body.healthStatus = args.healthStatus;
|
||||
if (typeof args.notes === "string") body.notes = args.notes;
|
||||
return callApi(request, origin, "PATCH", `/api/v1/garden/plants/${plantId}`, body);
|
||||
}
|
||||
case "delete_garden_plant": {
|
||||
const plantId = requireString(args, "plantId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/garden/plants/${plantId}`);
|
||||
}
|
||||
case "log_garden_care": {
|
||||
const plantId = requireString(args, "plantId");
|
||||
const body: Record<string, unknown> = { careType: requireString(args, "careType") };
|
||||
if (typeof args.notes === "string") body.notes = args.notes;
|
||||
if (typeof args.performedAt === "string") body.performedAt = args.performedAt;
|
||||
return callApi(request, origin, "POST", `/api/v1/garden/plants/${plantId}/care-logs`, body);
|
||||
}
|
||||
case "list_garden_care_logs": {
|
||||
const plantId = requireString(args, "plantId");
|
||||
const limit = typeof args.limit === "number" ? `?limit=${args.limit}` : "";
|
||||
return callApi(request, origin, "GET", `/api/v1/garden/plants/${plantId}/care-logs${limit}`);
|
||||
}
|
||||
case "list_garden_care_schedules": {
|
||||
const plantId = requireString(args, "plantId");
|
||||
return callApi(request, origin, "GET", `/api/v1/garden/plants/${plantId}/care-schedules`);
|
||||
}
|
||||
case "upsert_garden_care_schedule": {
|
||||
const plantId = requireString(args, "plantId");
|
||||
const body: Record<string, unknown> = {
|
||||
careType: requireString(args, "careType"),
|
||||
intervalDays: requireNumber(args, "intervalDays"),
|
||||
};
|
||||
if (typeof args.enabled === "boolean") body.enabled = args.enabled;
|
||||
return callApi(
|
||||
request,
|
||||
origin,
|
||||
"POST",
|
||||
`/api/v1/garden/plants/${plantId}/care-schedules`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
case "delete_garden_care_schedule": {
|
||||
const scheduleId = requireString(args, "scheduleId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/garden/care-schedules/${scheduleId}`);
|
||||
}
|
||||
case "toggle_garden_care_schedule": {
|
||||
const scheduleId = requireString(args, "scheduleId");
|
||||
const body = { enabled: requireBoolean(args, "enabled") };
|
||||
return callApi(request, origin, "PATCH", `/api/v1/garden/care-schedules/${scheduleId}`, body);
|
||||
}
|
||||
case "push_overdue_garden_care":
|
||||
return callApi(request, origin, "POST", "/api/v1/garden/overdue-care/push");
|
||||
case "schedule_garden_care_on_calendar": {
|
||||
const scheduleId = requireString(args, "scheduleId");
|
||||
const body: Record<string, unknown> = {
|
||||
calendarId: requireString(args, "calendarId"),
|
||||
};
|
||||
if (typeof args.reminderMinutesBefore === "number") {
|
||||
body.reminderMinutesBefore = args.reminderMinutesBefore;
|
||||
}
|
||||
return callApi(
|
||||
request,
|
||||
origin,
|
||||
"POST",
|
||||
`/api/v1/garden/care-schedules/${scheduleId}/calendar`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
case "list_shareable_entity_types":
|
||||
return callApi(request, origin, "GET", "/api/v1/share-links?entityTypes=true");
|
||||
case "list_share_links": {
|
||||
const qs = new URLSearchParams();
|
||||
if (typeof args.entityType === "string") qs.set("entityType", args.entityType);
|
||||
if (typeof args.entityId === "string") qs.set("entityId", args.entityId);
|
||||
const query = qs.toString();
|
||||
return callApi(
|
||||
request,
|
||||
origin,
|
||||
"GET",
|
||||
query ? `/api/v1/share-links?${query}` : "/api/v1/share-links",
|
||||
);
|
||||
}
|
||||
case "create_share_link": {
|
||||
const body: Record<string, unknown> = {
|
||||
entityType: requireString(args, "entityType"),
|
||||
entityId: requireString(args, "entityId"),
|
||||
};
|
||||
if (typeof args.expiresAt === "string") body.expiresAt = args.expiresAt;
|
||||
if (typeof args.write === "boolean") {
|
||||
body.capabilities = { write: args.write };
|
||||
}
|
||||
return callApi(request, origin, "POST", "/api/v1/share-links", body);
|
||||
}
|
||||
case "revoke_share_link": {
|
||||
const linkId = requireString(args, "linkId");
|
||||
return callApi(request, origin, "DELETE", `/api/v1/share-links/${linkId}`);
|
||||
}
|
||||
case "get_api_docs":
|
||||
return getApiDocs(args);
|
||||
case "call_api":
|
||||
return callApiFallback(args, request, origin);
|
||||
default:
|
||||
return { status: 400, body: { error: `Unknown tool: ${name}` } };
|
||||
}
|
||||
@@ -115,6 +383,87 @@ function requireString(args: Record<string, unknown>, key: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNumber(args: Record<string, unknown>, key: string): number {
|
||||
const value = args[key];
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error(`Missing required argument: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireBoolean(args: Record<string, unknown>, key: string): boolean {
|
||||
const value = args[key];
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`Missing required argument: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function getApiDocs(args: Record<string, unknown>): Promise<ApiCallResult> {
|
||||
const { readFile } = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
|
||||
const spec = await readFile(specPath, "utf8");
|
||||
const search = typeof args.search === "string" ? args.search.trim().toLowerCase() : "";
|
||||
|
||||
if (!search) {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
spec,
|
||||
hint: "Pass search to filter paths, or use call_api with a /api/v1/* path.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const lines = spec.split("\n");
|
||||
const matches = lines.filter((line) => line.toLowerCase().includes(search));
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
search,
|
||||
matchCount: matches.length,
|
||||
matches: matches.slice(0, 100),
|
||||
hint: "Use call_api with method and path from the matches above.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function callApiFallback(
|
||||
args: Record<string, unknown>,
|
||||
request: Request,
|
||||
origin: string,
|
||||
): Promise<ApiCallResult> {
|
||||
const method = requireString(args, "method").toUpperCase();
|
||||
if (!["GET", "POST", "PATCH", "DELETE"].includes(method)) {
|
||||
return { status: 400, body: { error: "method must be GET, POST, PATCH, or DELETE" } };
|
||||
}
|
||||
|
||||
let apiPath = requireString(args, "path");
|
||||
if (!apiPath.startsWith("/api/v1/")) {
|
||||
return { status: 400, body: { error: "path must start with /api/v1/" } };
|
||||
}
|
||||
if (apiPath.includes("..")) {
|
||||
return { status: 400, body: { error: "invalid path" } };
|
||||
}
|
||||
|
||||
if (typeof args.query === "object" && args.query !== null && !Array.isArray(args.query)) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(args.query as Record<string, unknown>)) {
|
||||
if (value !== undefined && value !== null) qs.set(key, String(value));
|
||||
}
|
||||
const query = qs.toString();
|
||||
if (query) apiPath += `?${query}`;
|
||||
}
|
||||
|
||||
const body =
|
||||
typeof args.body === "object" && args.body !== null && !Array.isArray(args.body)
|
||||
? (args.body as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
return callApi(request, origin, method, apiPath, body);
|
||||
}
|
||||
|
||||
async function callApi(
|
||||
request: Request,
|
||||
origin: string,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
function parseArgs(argsJson: string): Record<string, unknown> {
|
||||
if (!argsJson.trim()) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(argsJson);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed tool args in UI copy
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function str(args: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = args[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function describeToolActivity(name: string, argsJson = ""): string {
|
||||
const args = parseArgs(argsJson);
|
||||
|
||||
switch (name) {
|
||||
case "list_lists":
|
||||
return str(args, "type") ? `Looking up your ${args.type} lists…` : "Looking up your lists…";
|
||||
case "list_list_items":
|
||||
return "Reading list items…";
|
||||
case "add_list_item": {
|
||||
const text = str(args, "text");
|
||||
return text ? `Adding “${text}” to a list…` : "Adding an item to a list…";
|
||||
}
|
||||
case "update_list_item":
|
||||
return typeof args.done === "boolean" && args.done
|
||||
? "Marking a list item complete…"
|
||||
: "Updating a list item…";
|
||||
case "delete_list_item":
|
||||
return "Removing a list item…";
|
||||
case "create_list":
|
||||
return str(args, "name") ? `Creating list “${args.name}”…` : "Creating a new list…";
|
||||
case "update_list":
|
||||
return "Updating a list…";
|
||||
case "delete_list":
|
||||
return "Deleting a list…";
|
||||
case "list_calendars":
|
||||
return "Looking up calendars…";
|
||||
case "list_events":
|
||||
return "Checking calendar events…";
|
||||
case "create_event":
|
||||
return str(args, "title") ? `Creating event “${args.title}”…` : "Creating a calendar event…";
|
||||
case "update_event":
|
||||
return "Updating a calendar event…";
|
||||
case "delete_event":
|
||||
return "Deleting a calendar event…";
|
||||
case "create_calendar":
|
||||
return str(args, "name") ? `Creating calendar “${args.name}”…` : "Creating a calendar…";
|
||||
case "list_notes":
|
||||
return "Looking up notes…";
|
||||
case "create_note":
|
||||
return str(args, "title") ? `Creating note “${args.title}”…` : "Creating a note…";
|
||||
case "update_note":
|
||||
return "Updating a note…";
|
||||
case "delete_note":
|
||||
return "Deleting a note…";
|
||||
case "list_journal_entries":
|
||||
return "Reading journal entries…";
|
||||
case "create_journal_entry":
|
||||
return "Saving a journal entry…";
|
||||
case "update_journal_entry":
|
||||
return "Updating a journal entry…";
|
||||
case "delete_journal_entry":
|
||||
return "Deleting a journal entry…";
|
||||
case "list_bangs":
|
||||
return "Checking bang counter…";
|
||||
case "add_bang":
|
||||
return "Recording a bang…";
|
||||
case "update_bang":
|
||||
return "Updating a bang entry…";
|
||||
case "delete_bang":
|
||||
return "Deleting a bang entry…";
|
||||
case "list_garden_containers":
|
||||
return "Looking up garden containers…";
|
||||
case "list_garden_plants":
|
||||
return "Looking up plants…";
|
||||
case "get_garden_plant":
|
||||
return "Loading plant details…";
|
||||
case "create_garden_container":
|
||||
return str(args, "name") ? `Adding container “${args.name}”…` : "Adding a garden container…";
|
||||
case "update_garden_container":
|
||||
return "Updating a garden container…";
|
||||
case "delete_garden_container":
|
||||
return "Removing a garden container…";
|
||||
case "create_garden_plant":
|
||||
return str(args, "name") ? `Adding plant “${args.name}”…` : "Adding a plant…";
|
||||
case "update_garden_plant":
|
||||
return "Updating a plant…";
|
||||
case "delete_garden_plant":
|
||||
return "Removing a plant…";
|
||||
case "log_garden_care": {
|
||||
const care = str(args, "careType");
|
||||
return care ? `Logging ${care} care…` : "Logging plant care…";
|
||||
}
|
||||
case "list_garden_care_logs":
|
||||
return "Reading care history…";
|
||||
case "list_garden_care_schedules":
|
||||
return "Checking care schedules…";
|
||||
case "upsert_garden_care_schedule":
|
||||
return "Updating a care schedule…";
|
||||
case "delete_garden_care_schedule":
|
||||
return "Removing a care schedule…";
|
||||
case "toggle_garden_care_schedule":
|
||||
return "Toggling a care schedule…";
|
||||
case "push_overdue_garden_care":
|
||||
return "Pushing overdue garden tasks…";
|
||||
case "schedule_garden_care_on_calendar":
|
||||
return "Adding garden care to calendar…";
|
||||
case "list_shareable_entity_types":
|
||||
return "Checking what can be shared…";
|
||||
case "list_share_links":
|
||||
return "Looking up share links…";
|
||||
case "create_share_link":
|
||||
return "Creating a share link…";
|
||||
case "revoke_share_link":
|
||||
return "Revoking a share link…";
|
||||
case "get_api_docs":
|
||||
return str(args, "search")
|
||||
? `Reading API docs for “${args.search}”…`
|
||||
: "Reading API documentation…";
|
||||
case "call_api": {
|
||||
const path = str(args, "path");
|
||||
return path ? `Calling ${path}…` : "Calling the API…";
|
||||
}
|
||||
default:
|
||||
return `Running ${name.replaceAll("_", " ")}…`;
|
||||
}
|
||||
}
|
||||
+640
-2
@@ -1,4 +1,7 @@
|
||||
import type { AgentToolDefinition } from "@/lib/llm";
|
||||
import { MOOD_CATALOG } from "@/modules/journal/mood-catalog";
|
||||
|
||||
const JOURNAL_MOOD_IDS = MOOD_CATALOG.map((mood) => mood.id).join(", ");
|
||||
|
||||
export const AGENT_TOOLS: AgentToolDefinition[] = [
|
||||
{
|
||||
@@ -46,6 +49,85 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_list_item",
|
||||
description:
|
||||
"Update a list item — mark done/undone, rename, or change quantity. Use list_list_items first to resolve itemId.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
listId: { type: "string", description: "UUID of the list" },
|
||||
itemId: { type: "string", description: "UUID of the item" },
|
||||
done: { type: "boolean", description: "Mark completed (true) or open (false)" },
|
||||
text: { type: "string", description: "New item text" },
|
||||
qty: { type: "string", description: "New quantity" },
|
||||
},
|
||||
required: ["listId", "itemId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_list_item",
|
||||
description: "Remove an item from a list permanently.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
listId: { type: "string", description: "UUID of the list" },
|
||||
itemId: { type: "string", description: "UUID of the item" },
|
||||
},
|
||||
required: ["listId", "itemId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_list",
|
||||
description: "Create a new list (e.g. type shopping or tasks).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", description: "List type, e.g. shopping or tasks" },
|
||||
name: { type: "string", description: "Display name" },
|
||||
},
|
||||
required: ["type", "name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_list",
|
||||
description: "Rename a list or archive/unarchive it.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
listId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
archived: { type: "boolean" },
|
||||
},
|
||||
required: ["listId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_list",
|
||||
description: "Permanently delete a list and all its items.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
listId: { type: "string" },
|
||||
},
|
||||
required: ["listId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -88,11 +170,66 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
|
||||
allDay: { type: "boolean" },
|
||||
location: { type: "string" },
|
||||
notes: { type: "string" },
|
||||
remindMinutesBefore: {
|
||||
type: "number",
|
||||
description: "Optional reminder N minutes before start",
|
||||
},
|
||||
},
|
||||
required: ["calendarId", "title", "startAt", "endAt"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_event",
|
||||
description: "Update or reschedule a calendar event.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
eventId: { type: "string" },
|
||||
title: { type: "string" },
|
||||
startAt: { type: "string" },
|
||||
endAt: { type: "string" },
|
||||
allDay: { type: "boolean" },
|
||||
location: { type: "string" },
|
||||
notes: { type: "string" },
|
||||
remindMinutesBefore: { type: "number" },
|
||||
},
|
||||
required: ["eventId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_event",
|
||||
description: "Delete a calendar event.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
eventId: { type: "string" },
|
||||
},
|
||||
required: ["eventId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_calendar",
|
||||
description: "Create a new calendar.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
color: { type: "string" },
|
||||
visibility: { type: "string", description: "private or household" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -112,11 +249,44 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
|
||||
title: { type: "string" },
|
||||
body: { type: "string" },
|
||||
pinned: { type: "boolean" },
|
||||
remindAt: { type: "string", description: "ISO 8601 reminder datetime, or null to clear" },
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_note",
|
||||
description: "Update a note's title, body, pin state, or reminder.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
noteId: { type: "string" },
|
||||
title: { type: "string" },
|
||||
body: { type: "string" },
|
||||
pinned: { type: "boolean" },
|
||||
remindAt: { type: "string", description: "ISO 8601 datetime or null" },
|
||||
},
|
||||
required: ["noteId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_note",
|
||||
description: "Delete a note.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
noteId: { type: "string" },
|
||||
},
|
||||
required: ["noteId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -130,10 +300,478 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_journal_entry",
|
||||
description:
|
||||
"Create a journal entry for the current user. Supports moods, stress (1-10), and pillsTaken.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
recordedAt: { type: "string", description: "ISO 8601 date/time for the entry" },
|
||||
title: { type: "string" },
|
||||
body: { type: "string" },
|
||||
moods: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: `Mood ids: ${JOURNAL_MOOD_IDS}`,
|
||||
},
|
||||
stress: { type: "number", description: "Stress level 1-10" },
|
||||
pillsTaken: { type: "boolean", description: "Whether pills were taken" },
|
||||
},
|
||||
required: ["recordedAt"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_journal_entry",
|
||||
description: "Update a journal entry including moods, stress, and pillsTaken.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entryId: { type: "string" },
|
||||
recordedAt: { type: "string" },
|
||||
title: { type: "string" },
|
||||
body: { type: "string" },
|
||||
moods: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: `Mood ids: ${JOURNAL_MOOD_IDS}`,
|
||||
},
|
||||
stress: { type: "number" },
|
||||
pillsTaken: { type: "boolean" },
|
||||
},
|
||||
required: ["entryId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_journal_entry",
|
||||
description: "Delete a journal entry.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entryId: { type: "string" },
|
||||
},
|
||||
required: ["entryId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_bangs",
|
||||
description: "Get bang counter stats and recent entries for the household.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "number", description: "Recent entries to include (default 10)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "add_bang",
|
||||
description: "Record a new bang. Date defaults to today if omitted.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
occurredOn: { type: "string", description: "YYYY-MM-DD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_bang",
|
||||
description: "Change the date of an existing bang entry.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bangId: { type: "string" },
|
||||
occurredOn: { type: "string", description: "YYYY-MM-DD" },
|
||||
},
|
||||
required: ["bangId", "occurredOn"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_bang",
|
||||
description: "Delete a bang entry.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bangId: { type: "string" },
|
||||
},
|
||||
required: ["bangId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_garden_containers",
|
||||
description: "List garden containers (pots, beds, etc.).",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_garden_plants",
|
||||
description: "List plants. Optionally filter by containerId.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
containerId: { type: "string", description: "UUID of container" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_garden_plant",
|
||||
description: "Get full detail for a plant by id.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { plantId: { type: "string" } },
|
||||
required: ["plantId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_garden_container",
|
||||
description: "Create a garden container.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
type: { type: "string", description: "e.g. pot, bed, greenhouse" },
|
||||
locationNotes: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_garden_container",
|
||||
description: "Update a garden container.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
containerId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
type: { type: "string" },
|
||||
locationNotes: { type: "string" },
|
||||
},
|
||||
required: ["containerId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_garden_container",
|
||||
description: "Delete a garden container.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { containerId: { type: "string" } },
|
||||
required: ["containerId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_garden_plant",
|
||||
description: "Add a new plant to the garden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
category: { type: "string" },
|
||||
containerId: { type: "string" },
|
||||
healthStatus: { type: "string" },
|
||||
notes: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_garden_plant",
|
||||
description: "Update plant details (name, health, container, notes, etc.).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
plantId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
category: { type: "string" },
|
||||
containerId: { type: "string" },
|
||||
healthStatus: { type: "string" },
|
||||
notes: { type: "string" },
|
||||
},
|
||||
required: ["plantId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_garden_plant",
|
||||
description: "Delete a plant.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { plantId: { type: "string" } },
|
||||
required: ["plantId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "log_garden_care",
|
||||
description: "Record care performed on a plant (water, fertilize, prune, etc.).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
plantId: { type: "string" },
|
||||
careType: { type: "string", description: "e.g. water, fertilize, prune" },
|
||||
notes: { type: "string" },
|
||||
performedAt: { type: "string", description: "ISO 8601; defaults to now" },
|
||||
},
|
||||
required: ["plantId", "careType"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_garden_care_logs",
|
||||
description: "List recent care logs for a plant.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
plantId: { type: "string" },
|
||||
limit: { type: "number" },
|
||||
},
|
||||
required: ["plantId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_garden_care_schedules",
|
||||
description: "List care schedules (recurring reminders) for a plant.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { plantId: { type: "string" } },
|
||||
required: ["plantId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "upsert_garden_care_schedule",
|
||||
description: "Create or update a recurring care schedule for a plant.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
plantId: { type: "string" },
|
||||
careType: { type: "string" },
|
||||
intervalDays: { type: "number", description: "Days between care" },
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
required: ["plantId", "careType", "intervalDays"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_garden_care_schedule",
|
||||
description: "Delete a care schedule by schedule id.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { scheduleId: { type: "string" } },
|
||||
required: ["scheduleId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "toggle_garden_care_schedule",
|
||||
description: "Enable or disable a care schedule.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
scheduleId: { type: "string" },
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
required: ["scheduleId", "enabled"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "push_overdue_garden_care",
|
||||
description: "Push overdue garden care items to the household task list.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "schedule_garden_care_on_calendar",
|
||||
description: "Add a care schedule's next due date as a calendar event.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
scheduleId: { type: "string" },
|
||||
calendarId: { type: "string" },
|
||||
reminderMinutesBefore: { type: "number" },
|
||||
},
|
||||
required: ["scheduleId", "calendarId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_shareable_entity_types",
|
||||
description: "List entity types that can be shared via public links.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_share_links",
|
||||
description: "List active share links. Optionally filter by entityType and entityId.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entityType: { type: "string" },
|
||||
entityId: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_share_link",
|
||||
description:
|
||||
"Create a temporary public share link for an entity. Entity types: calendar, calendar.event, list, note, garden.plant, garden.container.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entityType: { type: "string" },
|
||||
entityId: { type: "string" },
|
||||
expiresAt: { type: "string", description: "ISO 8601 expiry, or omit for no expiry" },
|
||||
write: { type: "boolean", description: "Allow write via link (default false)" },
|
||||
},
|
||||
required: ["entityType", "entityId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "revoke_share_link",
|
||||
description: "Revoke an active share link by its id.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { linkId: { type: "string" } },
|
||||
required: ["linkId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_api_docs",
|
||||
description:
|
||||
"Read famapp REST API documentation (OpenAPI). Use when unsure which endpoint to call or no dedicated tool exists. Pass search to filter relevant paths.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
search: {
|
||||
type: "string",
|
||||
description: "Optional keyword to filter paths (e.g. garden, share, journal)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "call_api",
|
||||
description:
|
||||
"Fallback: call any /api/v1/* endpoint directly. Use get_api_docs first when unsure. Only household-scoped v1 routes.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
method: {
|
||||
type: "string",
|
||||
enum: ["GET", "POST", "PATCH", "DELETE"],
|
||||
description: "HTTP method",
|
||||
},
|
||||
path: {
|
||||
type: "string",
|
||||
description: "API path starting with /api/v1/ (e.g. /api/v1/notes)",
|
||||
},
|
||||
query: {
|
||||
type: "object",
|
||||
description: "Optional query string parameters as key-value pairs",
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
description: "Optional JSON body for POST/PATCH",
|
||||
},
|
||||
},
|
||||
required: ["method", "path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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.
|
||||
export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help Matt and his wife manage calendar events, shopping and task lists, notes, journal entries, garden plants, share links, and the bang counter.
|
||||
|
||||
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.`;
|
||||
When no dedicated tool fits, or you are unsure how to do something:
|
||||
1. Call get_api_docs with a relevant search term to find the right /api/v1/* endpoint.
|
||||
2. Call call_api with the documented method, path, query, and body.
|
||||
|
||||
Lists: resolve list ids via list_lists. To complete items, list_list_items then update_list_item with done: true.
|
||||
|
||||
Journal: per-user private entries. Valid mood ids: ${JOURNAL_MOOD_IDS}. stress is 1-10. pillsTaken is boolean.
|
||||
|
||||
Garden: care types are free text (water, fertilize, prune, etc.). Use list_garden_plants to find plant ids.
|
||||
|
||||
Sharing: journal entries are not shareable. Shareable types: calendar, calendar.event, list, note, garden.plant, garden.container.
|
||||
|
||||
Calendar: use ISO 8601 datetimes. Bang dates use YYYY-MM-DD.`;
|
||||
|
||||
Reference in New Issue
Block a user