# Conversation Summary
## Conversation Overview A single user message. The user pasted a large, partially garbled/mangled unified diff of changes to a **Next.js self‑hosted board/to‑do web app** and asked the assistant to *"Generate a concise and descriptive git commit message for the selected code changes. The message should summarize the changes made, their purpose, and any relevant context. Do not edit any files, just provide the commit message as output."* No assistant reply has been produced yet. ## Subject of the Diff (what the changes do) The diff introduces two related **AI features** plus shared AI plumbing: 1. **Per‑group "Status Update" feature** - New `components/board/status-update-dialog.tsx` — a dialog that generates a group status update, supports a follow‑up chat list (`ChatMessageList`, `MarkdownPreview`), copy‑to‑clipboard, and calls `converseAboutGroupStatus(group.id, next)`. - `components/board/group-card.tsx` (modified) — imports `ClipboardList` + `StatusUpdateDialog`, adds `statusUpdateOpen` state, and a **"Status Update"** dropdown item gated on `aiConfigured`. - New `lib/actions/group-status-ai.ts` (`"use server"`) — backing server action(s) incl. `converseAboutGroupStatus`. 2. **"Global Chat" feature** - New `lib/actions/global-chat.ts` (`"use server"`) — `sendChatMessage(messages: ChatMessage[])`: free‑form Q&A across every board the user owns (Home + all Projects). Re‑fetches/re‑serializes context each turn; uses `requireUserId()`, `getAllGroupsForUser()`, `formatAllGroupsForPrompt()`, and a shared `callChatCompletion` helper. Includes a `SYSTEM_PROMPT_HEADER` instructing the model to answer only from provided context. 3. **Shared AI context/formatting layer** - New `lib/ai/context.ts` (`server-only`) — `formatAllGroupsForPrompt`, truncation with hard caps: `NOTE_CHAR_CAP=4,000`, `DETAILS_CHAR_CAP=300`, `TOTAL_CHAR_CAP=60,000`. Comment notes single‑user scale (no RAG; whole dataset goes into the prompt, capped by these limits). - New `types/ai.ts` — `AiGroupContext` interface (group + scope label + category + archived flag + todos). **Deliberately includes archived groups** so historical questions are answerable. - `lib/board.ts` (modified) — new `getAllGroupsForUser(userId)` using one Prisma query with `categoryAccessFilter`, flattening all categories/groups/todos (unlike `getBoard`, it does **not** filter archived groups). ## Key Technical Details - Stack: Next.js, React, Tailwind, shadcn‑style UI (Dialog/Button), `MarkdownPreview`/`ChatMessageList`. - `"use server"` server actions; `"server-only"` context module; Prisma ORM; `requireUserId()` auth; `categoryAccessFilter` access control. - A shared `callChatCompletion` helper is referenced (handles provider errors, 401/403 API‑key rejection, empty/invalid JSON responses) — its full definition is not cleanly visible in the pasted diff. - The pasted diff/file contents contain visible corruption (broken lines, garbled characters), but the feature intent is clearly discernible. ## User Request (deliverable) Produce **only** a concise, descriptive git commit message (no file edits). The expected message should summarize: adding per‑group AI "Status Update" + a global cross‑board "Chat", along with shared AI context formatting (`lib/ai/context.ts`, `types/ai.ts`, `getAllGroupsForUser`) and new server actions (`global-chat.ts`, `group-status-ai.ts`). A representative phrasing would be something like: *"Add AI status updates per group and a global chat across all boards, backed by shared AI context formatting."* ## Current State / Next Steps - No assistant response exists yet. - Next step is to emit the commit message as plain text output (no code/file changes).
This commit is contained in:
parent
eb32aee653
commit
6cfd244b41
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getAiSettingsView } from "@/lib/ai-settings";
|
||||||
|
import { ChatPageClient } from "@/components/chat/chat-page-client";
|
||||||
|
|
||||||
|
export default async function ChatPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const aiSettings = await getAiSettingsView();
|
||||||
|
const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model);
|
||||||
|
|
||||||
|
// No board data fetched here -- sendChatMessage (lib/actions/chat-ai.ts)
|
||||||
|
// gathers and re-serializes the user's full context itself on every
|
||||||
|
// turn, so this page only needs the aiConfigured gate.
|
||||||
|
return <ChatPageClient aiConfigured={aiConfigured} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Mic, Send, Square } from "lucide-react";
|
||||||
|
import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
||||||
|
|
||||||
|
export interface ChatBubbleMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared message-bubble list for the two plain-text chat surfaces (the
|
||||||
|
* global Chat page and the group Status Update follow-up Q&A) -- both
|
||||||
|
* just render a back-and-forth conversation with no ask/finalize review
|
||||||
|
* step, unlike GroupAiDialog/TodoAiDialog (which inline their own nearly
|
||||||
|
* identical list because they also render an editable "pending" state
|
||||||
|
* this shared component doesn't need to know about).
|
||||||
|
*/
|
||||||
|
export function ChatMessageList({
|
||||||
|
messages,
|
||||||
|
sending,
|
||||||
|
error,
|
||||||
|
emptyHint,
|
||||||
|
colorMode,
|
||||||
|
}: {
|
||||||
|
messages: ChatBubbleMessage[];
|
||||||
|
sending: boolean;
|
||||||
|
error: string | null;
|
||||||
|
emptyHint: string;
|
||||||
|
colorMode?: "light" | "dark";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-3 overflow-y-auto">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{emptyHint}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
messages.map((message, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
|
||||||
|
message.role === "user"
|
||||||
|
? "ml-auto bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-foreground"
|
||||||
|
)}
|
||||||
|
data-color-mode={message.role === "assistant" ? colorMode : undefined}
|
||||||
|
>
|
||||||
|
{message.role === "assistant" ? (
|
||||||
|
<MarkdownPreview source={message.content} />
|
||||||
|
) : (
|
||||||
|
message.content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{sending && (
|
||||||
|
<div className="max-w-[85%] rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
Thinking…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared textarea + mic + send composer, matching the input row already
|
||||||
|
* used by GroupAiDialog/TodoAiDialog (voice input via
|
||||||
|
* useSpeechRecognition, Enter-to-send, Shift+Enter for a newline).
|
||||||
|
*/
|
||||||
|
export function ChatComposer({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSend,
|
||||||
|
sending,
|
||||||
|
listening,
|
||||||
|
micSupported,
|
||||||
|
onStartListening,
|
||||||
|
onStopListening,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
sending: boolean;
|
||||||
|
listening: boolean;
|
||||||
|
micSupported: boolean;
|
||||||
|
onStartListening: () => void;
|
||||||
|
onStopListening: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder ?? (listening ? "Listening…" : "Type or use the microphone…")}
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 resize-none"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{micSupported && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={listening ? "destructive" : "outline"}
|
||||||
|
size="icon"
|
||||||
|
onClick={listening ? onStopListening : onStartListening}
|
||||||
|
disabled={sending}
|
||||||
|
aria-label={listening ? "Stop recording" : "Record voice input"}
|
||||||
|
>
|
||||||
|
{listening ? <Square className="size-4" /> : <Mic className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
onClick={onSend}
|
||||||
|
disabled={sending || !value.trim()}
|
||||||
|
aria-label="Send"
|
||||||
|
>
|
||||||
|
<Send className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
ClipboardList,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
|
@ -33,6 +34,7 @@ import { TodoCreatePopover } from "@/components/board/todo-create-popover";
|
||||||
import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
|
import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
|
||||||
import { TodoProgressPie } from "@/components/board/todo-progress-pie";
|
import { TodoProgressPie } from "@/components/board/todo-progress-pie";
|
||||||
import { EditGroupDialog } from "@/components/board/edit-group-dialog";
|
import { EditGroupDialog } from "@/components/board/edit-group-dialog";
|
||||||
|
import { StatusUpdateDialog } from "@/components/board/status-update-dialog";
|
||||||
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||||
import type { GroupDTO, TodoDTO } from "@/types/board";
|
import type { GroupDTO, TodoDTO } from "@/types/board";
|
||||||
|
|
||||||
|
|
@ -43,6 +45,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
||||||
|
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
|
|
@ -137,6 +140,12 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{aiConfigured && (
|
||||||
|
<DropdownMenuItem onClick={() => setStatusUpdateOpen(true)}>
|
||||||
|
<ClipboardList className="size-4" />
|
||||||
|
Status Update
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
|
<DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
|
|
@ -237,6 +246,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
|
|
||||||
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
||||||
|
|
||||||
|
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
onOpenChange={setConfirmOpen}
|
onOpenChange={setConfirmOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { NotebookPen, Pencil, Sparkles } from "lucide-react";
|
import { NotebookPen, Pencil, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -36,6 +36,15 @@ export function NotesDialog({ group }: { group: GroupDTO }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The "Ask AI" dialog can update this group's notes (via saveNote) while
|
||||||
|
// this dialog stays open behind it. Pick up that change so the accepted
|
||||||
|
// AI notes show up here immediately instead of the stale pre-AI draft.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !editing) {
|
||||||
|
setDraft(group.noteContent);
|
||||||
|
}
|
||||||
|
}, [group.noteContent, open, editing]);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const ok = await saveNote(group.id, group.categoryId, draft);
|
const ok = await saveNote(group.id, group.categoryId, draft);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Check, Copy, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
||||||
|
import { ChatMessageList, ChatComposer, type ChatBubbleMessage } from "@/components/ai/chat-thread";
|
||||||
|
import { useSpeechRecognition } from "@/hooks/use-speech-recognition";
|
||||||
|
import {
|
||||||
|
converseAboutGroupStatus,
|
||||||
|
type StatusChatMessage,
|
||||||
|
} from "@/lib/actions/group-status-ai";
|
||||||
|
import type { GroupDTO } from "@/types/board";
|
||||||
|
|
||||||
|
export function StatusUpdateDialog({
|
||||||
|
group,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
group: GroupDTO;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
|
// The generated summary, shown permanently once produced -- there's no
|
||||||
|
// accept/discard step, since nothing here is ever saved back to the
|
||||||
|
// group's notes (export-only, by design).
|
||||||
|
const [summary, setSummary] = useState<string | null>(null);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<StatusChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { supported: micSupported, listening, start, stop } = useSpeechRecognition({
|
||||||
|
onFinalResult: (transcript) =>
|
||||||
|
setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)),
|
||||||
|
onError: setError,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fires the initial summary the moment the dialog opens -- no user
|
||||||
|
// action needed, per spec. Guarded by summary === null so it doesn't
|
||||||
|
// re-fire on every re-render while open, and skipped once a summary (or
|
||||||
|
// an error in its place) already exists for this open session.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || summary !== null || generating) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
converseAboutGroupStatus(group.id, []).then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setGenerating(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSummary(result.content);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Starts fresh every time it's reopened -- matches GroupAiDialog/
|
||||||
|
// TodoAiDialog's existing behavior.
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
if (!next) {
|
||||||
|
stop();
|
||||||
|
setSummary(null);
|
||||||
|
setGenerating(false);
|
||||||
|
setCopied(false);
|
||||||
|
setMessages([]);
|
||||||
|
setInput("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
onOpenChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
if (!summary) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(summary);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success("Copied to clipboard.");
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast.error("Couldn't copy to clipboard.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || sending) return;
|
||||||
|
if (listening) stop();
|
||||||
|
|
||||||
|
const next: StatusChatMessage[] = [...messages, { role: "user", content: text }];
|
||||||
|
setMessages(next);
|
||||||
|
setInput("");
|
||||||
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
|
const result = await converseAboutGroupStatus(group.id, next);
|
||||||
|
setSending(false);
|
||||||
|
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessages([...next, { role: "assistant", content: result.content }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[80vh] flex-col sm:max-w-lg" data-color-mode={colorMode}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="size-4 text-primary" />
|
||||||
|
Status Update — {group.title}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{generating && !summary && (
|
||||||
|
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||||
|
Generating status update…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !summary && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
||||||
|
<div className="max-h-[35vh] shrink-0 overflow-y-auto rounded-md border p-4">
|
||||||
|
<MarkdownPreview source={summary} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit gap-2"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||||
|
{copied ? "Copied" : "Copy to clipboard"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
||||||
|
<ChatMessageList
|
||||||
|
messages={messages as ChatBubbleMessage[]}
|
||||||
|
sending={sending}
|
||||||
|
error={error}
|
||||||
|
emptyHint="Ask a follow-up question about this group."
|
||||||
|
colorMode={colorMode}
|
||||||
|
/>
|
||||||
|
<ChatComposer
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
onSend={handleSend}
|
||||||
|
sending={sending}
|
||||||
|
listening={listening}
|
||||||
|
micSupported={micSupported}
|
||||||
|
onStartListening={start}
|
||||||
|
onStopListening={stop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { MessageCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import { ChatMessageList, ChatComposer, type ChatBubbleMessage } from "@/components/ai/chat-thread";
|
||||||
|
import { useSpeechRecognition } from "@/hooks/use-speech-recognition";
|
||||||
|
import { sendChatMessage, type ChatMessage } from "@/lib/actions/chat-ai";
|
||||||
|
|
||||||
|
export function ChatPageClient({ aiConfigured }: { aiConfigured: boolean }) {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { supported: micSupported, listening, start, stop } = useSpeechRecognition({
|
||||||
|
onFinalResult: (transcript) =>
|
||||||
|
setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)),
|
||||||
|
onError: setError,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || sending) return;
|
||||||
|
if (listening) stop();
|
||||||
|
|
||||||
|
const next: ChatMessage[] = [...messages, { role: "user", content: text }];
|
||||||
|
setMessages(next);
|
||||||
|
setInput("");
|
||||||
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
|
const result = await sendChatMessage(next);
|
||||||
|
setSending(false);
|
||||||
|
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessages([...next, { role: "assistant", content: result.content }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-full max-w-2xl flex-col gap-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<MessageCircle className="size-6 text-primary" />
|
||||||
|
Chat
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Ask about anything across your boards — notes, open to-dos, and things you've
|
||||||
|
already finished.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!aiConfigured ? (
|
||||||
|
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||||
|
AI generation isn't configured yet. Ask an administrator to set it up.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col gap-3 overflow-hidden">
|
||||||
|
<ChatMessageList
|
||||||
|
messages={messages as ChatBubbleMessage[]}
|
||||||
|
sending={sending}
|
||||||
|
error={error}
|
||||||
|
emptyHint="Ask a question about your projects, groups, notes, or to-dos — done or still open."
|
||||||
|
colorMode={colorMode}
|
||||||
|
/>
|
||||||
|
<ChatComposer
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
onSend={handleSend}
|
||||||
|
sending={sending}
|
||||||
|
listening={listening}
|
||||||
|
micSupported={micSupported}
|
||||||
|
onStartListening={start}
|
||||||
|
onStopListening={stop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Home, ShieldUser } from "lucide-react";
|
import { Home, MessageCircle, ShieldUser } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
|
|
@ -9,7 +9,10 @@ export interface NavItem {
|
||||||
|
|
||||||
// Scaffolded with just "Home" per the spec -- trivially extended by pushing
|
// Scaffolded with just "Home" per the spec -- trivially extended by pushing
|
||||||
// more entries here later.
|
// more entries here later.
|
||||||
export const NAV_ITEMS: NavItem[] = [{ href: "/", label: "Home", icon: Home }];
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ href: "/", label: "Home", icon: Home },
|
||||||
|
{ href: "/chat", label: "Chat", icon: MessageCircle },
|
||||||
|
];
|
||||||
|
|
||||||
// Only shown to admins -- appended conditionally by SideNav, not part of
|
// Only shown to admins -- appended conditionally by SideNav, not part of
|
||||||
// the always-visible list above.
|
// the always-visible list above.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { requireUserId } from "@/lib/auth-helpers";
|
||||||
|
import { getAllGroupsForUser } from "@/lib/board";
|
||||||
|
import { formatAllGroupsForPrompt } from "@/lib/ai/context";
|
||||||
|
import { callChatCompletion } from "@/lib/ai/chat-completion";
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalChatResult = { content: string } | { error: string };
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_HEADER = `You are a helpful assistant answering questions about the user's personal/team to-do board app. Below is the full current state of every board they own, including completed to-dos and archived groups (kept so you can answer historical questions like "what did we do about X").
|
||||||
|
|
||||||
|
Answer only using the information below. If the answer isn't in it, say so plainly instead of guessing. When it's helpful, mention which project/category/group the information came from.
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free-form Q&A across everything the user owns (Home board + every
|
||||||
|
* Project), for the global Chat page. Re-fetches and re-serializes the
|
||||||
|
* full context on every turn -- simpler and always fresh, and re-checks
|
||||||
|
* the user's own access each call the same way the existing group-scoped
|
||||||
|
* AI actions do (lib/actions/group-ai.ts, lib/actions/todo-ai.ts).
|
||||||
|
* Unlike those, there's no artifact to save, so this returns the model's
|
||||||
|
* plain-text reply directly rather than an ask/finalize JSON contract.
|
||||||
|
*/
|
||||||
|
export async function sendChatMessage(messages: ChatMessage[]): Promise<GlobalChatResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
const groups = await getAllGroupsForUser(userId);
|
||||||
|
const context = formatAllGroupsForPrompt(groups);
|
||||||
|
|
||||||
|
const systemPrompt = `${SYSTEM_PROMPT_HEADER}${context}`;
|
||||||
|
|
||||||
|
return callChatCompletion([{ role: "system", content: systemPrompt }, ...messages], {
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUserId } from "@/lib/auth-helpers";
|
||||||
|
import { categoryAccessFilter } from "@/lib/access";
|
||||||
|
import { formatGroupForPrompt } from "@/lib/ai/context";
|
||||||
|
import { callChatCompletion } from "@/lib/ai/chat-completion";
|
||||||
|
|
||||||
|
export interface StatusChatMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusChatResult = { content: string } | { error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Powers the group card's "Status Update" feature: with no prior
|
||||||
|
* messages, produces an initial markdown status summary of the group
|
||||||
|
* (notes + todos); with prior messages, answers a follow-up question
|
||||||
|
* about it. One action rather than two "generate" / "ask" actions --
|
||||||
|
* both are the same shape of call (group context + message history in,
|
||||||
|
* plain text out), and splitting them would just duplicate the
|
||||||
|
* group-loading and system-prompt code for no behavioral benefit. Unlike
|
||||||
|
* lib/actions/group-ai.ts/todo-ai.ts, nothing here is ever saved, so
|
||||||
|
* there's no ask/finalize JSON contract to parse -- just plain text.
|
||||||
|
*/
|
||||||
|
export async function converseAboutGroupStatus(
|
||||||
|
groupId: string,
|
||||||
|
messages: StatusChatMessage[]
|
||||||
|
): Promise<StatusChatResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
|
||||||
|
const group = await prisma.group.findFirst({
|
||||||
|
where: { id: groupId, category: categoryAccessFilter(userId) },
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
noteContent: true,
|
||||||
|
todos: { orderBy: { order: "asc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!group) return { error: "Group not found." };
|
||||||
|
|
||||||
|
const context = formatGroupForPrompt({
|
||||||
|
title: group.title,
|
||||||
|
noteContent: group.noteContent,
|
||||||
|
todos: group.todos.map((todo) => ({
|
||||||
|
title: todo.title,
|
||||||
|
details: todo.details,
|
||||||
|
completed: todo.completed,
|
||||||
|
completedAt: todo.completedAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const systemPrompt = `You are summarizing one group ("${group.title}") from a to-do board for its owner. Here is everything known about it:
|
||||||
|
|
||||||
|
${context}
|
||||||
|
|
||||||
|
If there are no prior messages below, your reply must be a concise markdown status update: a couple of short paragraphs and/or a short list covering what this group is about, what's been done, and what's still outstanding. Do not repeat the group's title as a heading, and don't add commentary like "Here's the status update" -- output only the summary itself.
|
||||||
|
|
||||||
|
If there are prior messages below, they're a follow-up question about this group -- answer it using only the context above, in plain markdown, and say plainly if the answer isn't in the data.`;
|
||||||
|
|
||||||
|
// An empty conversation means "generate the initial summary" -- a
|
||||||
|
// synthetic, UI-invisible user turn keeps that a normal chat completion
|
||||||
|
// call instead of a special-cased empty-messages request.
|
||||||
|
const effectiveMessages: StatusChatMessage[] =
|
||||||
|
messages.length > 0 ? messages : [{ role: "user", content: "Generate the status update now." }];
|
||||||
|
|
||||||
|
return callChatCompletion([{ role: "system", content: systemPrompt }, ...effectiveMessages], {
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { getAiCredentials, getAiSettingsView, joinApiUrl } from "@/lib/ai-settings";
|
||||||
|
|
||||||
|
export interface ChatCompletionMessage {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatCompletionResult = { content: string } | { error: string };
|
||||||
|
|
||||||
|
function extractMessageContent(body: unknown): string | null {
|
||||||
|
if (!body || typeof body !== "object") return null;
|
||||||
|
const choices = (body as { choices?: unknown }).choices;
|
||||||
|
if (!Array.isArray(choices) || !choices[0]) return null;
|
||||||
|
const message = (choices[0] as { message?: unknown }).message;
|
||||||
|
if (!message || typeof message !== "object") return null;
|
||||||
|
const content = (message as { content?: unknown }).content;
|
||||||
|
return typeof content === "string" ? content : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared fetch/parse/error-handling for calling the configured
|
||||||
|
* OpenAI-compatible AI provider (see lib/ai-settings.ts) with a plain chat
|
||||||
|
* completion -- no structured ask/finalize contract, just "here's a
|
||||||
|
* conversation, give me back the assistant's reply". Used by features
|
||||||
|
* that want the model to answer freely (global chat, group status
|
||||||
|
* updates) rather than produce a JSON artifact to review-and-save; those
|
||||||
|
* artifact-producing features (lib/actions/group-ai.ts,
|
||||||
|
* lib/actions/todo-ai.ts) predate this helper and have their own
|
||||||
|
* ask/finalize parsing layered on top of the same fetch shape, so they're
|
||||||
|
* left as-is rather than retrofitted onto this.
|
||||||
|
*/
|
||||||
|
export async function callChatCompletion(
|
||||||
|
messages: ChatCompletionMessage[],
|
||||||
|
opts?: { temperature?: number }
|
||||||
|
): Promise<ChatCompletionResult> {
|
||||||
|
const [{ apiUrl, apiKey }, settings] = await Promise.all([
|
||||||
|
getAiCredentials(),
|
||||||
|
getAiSettingsView(),
|
||||||
|
]);
|
||||||
|
if (!apiUrl || !settings.model) {
|
||||||
|
return { error: "AI generation isn't configured yet. Ask an administrator to set it up." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let chatUrl: URL;
|
||||||
|
try {
|
||||||
|
chatUrl = joinApiUrl(apiUrl, "chat/completions");
|
||||||
|
} catch {
|
||||||
|
return { error: "The configured AI API URL is invalid." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(chatUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: settings.model,
|
||||||
|
messages,
|
||||||
|
temperature: opts?.temperature ?? 0.4,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return { error: `Couldn't reach the AI provider -- ${message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
return { error: "The AI provider rejected the configured API key." };
|
||||||
|
}
|
||||||
|
return { error: `The AI provider responded with ${response.status} ${response.statusText}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
return { error: "The AI provider didn't return valid JSON." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = extractMessageContent(body);
|
||||||
|
if (!content) return { error: "The AI provider's response was empty." };
|
||||||
|
|
||||||
|
return { content: content.trim() };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { AiGroupContext } from "@/types/ai";
|
||||||
|
|
||||||
|
// This app is single-user/self-hosted scale (see lib/access.ts's comments
|
||||||
|
// on Projects being single-owner for now) -- there's no retrieval/ranking
|
||||||
|
// here, the whole relevant dataset just goes straight into the prompt,
|
||||||
|
// truncated only by the hard caps below. If a board ever grows large
|
||||||
|
// enough for this to blow the model's context window, the fix is a
|
||||||
|
// RAG-style retrieval step, not a bigger cap.
|
||||||
|
const NOTE_CHAR_CAP = 4_000;
|
||||||
|
const DETAILS_CHAR_CAP = 300;
|
||||||
|
const TOTAL_CHAR_CAP = 60_000;
|
||||||
|
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length <= max) return trimmed;
|
||||||
|
return `${trimmed.slice(0, max)}… [truncated]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTodoLine(todo: {
|
||||||
|
title: string;
|
||||||
|
details: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
completedAt: string | null;
|
||||||
|
}): string {
|
||||||
|
const box = todo.completed ? "[x]" : "[ ]";
|
||||||
|
const doneNote = todo.completed && todo.completedAt ? ` (done ${todo.completedAt.slice(0, 10)})` : "";
|
||||||
|
const details = todo.details?.trim() ? ` — ${truncate(todo.details, DETAILS_CHAR_CAP)}` : "";
|
||||||
|
return `- ${box} ${todo.title}${doneNote}${details}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders one group's notes + todos as compact markdown-ish prompt text. */
|
||||||
|
export function formatGroupForPrompt(
|
||||||
|
group: {
|
||||||
|
title: string;
|
||||||
|
noteContent: string;
|
||||||
|
todos: { title: string; details: string | null; completed: boolean; completedAt: string | null }[];
|
||||||
|
},
|
||||||
|
opts?: { archived?: boolean }
|
||||||
|
): string {
|
||||||
|
const openCount = group.todos.filter((t) => !t.completed).length;
|
||||||
|
const doneCount = group.todos.length - openCount;
|
||||||
|
const notes = group.noteContent.trim()
|
||||||
|
? truncate(group.noteContent, NOTE_CHAR_CAP)
|
||||||
|
: "(no notes)";
|
||||||
|
const todoLines = group.todos.length > 0
|
||||||
|
? group.todos.map(formatTodoLine).join("\n")
|
||||||
|
: "(no to-dos)";
|
||||||
|
const archivedTag = opts?.archived ? " [archived]" : "";
|
||||||
|
|
||||||
|
return `Group${archivedTag}: "${group.title}"\n\nNotes:\n${notes}\n\nTo-dos (${openCount} open, ${doneCount} done):\n${todoLines}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders every group the user can see, grouped by board (Home/Project)
|
||||||
|
* then category, for the global chat's system prompt. Archived groups
|
||||||
|
* are marked inline rather than omitted, so historical questions stay
|
||||||
|
* answerable.
|
||||||
|
*/
|
||||||
|
export function formatAllGroupsForPrompt(groups: AiGroupContext[]): string {
|
||||||
|
const byScope = new Map<string, Map<string, AiGroupContext[]>>();
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!byScope.has(group.scopeLabel)) byScope.set(group.scopeLabel, new Map());
|
||||||
|
const byCategory = byScope.get(group.scopeLabel)!;
|
||||||
|
if (!byCategory.has(group.categoryName)) byCategory.set(group.categoryName, []);
|
||||||
|
byCategory.get(group.categoryName)!.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: string[] = [];
|
||||||
|
for (const [scopeLabel, byCategory] of byScope) {
|
||||||
|
sections.push(`# ${scopeLabel}`);
|
||||||
|
for (const [categoryName, categoryGroups] of byCategory) {
|
||||||
|
sections.push(`## Category: ${categoryName}`);
|
||||||
|
for (const group of categoryGroups) {
|
||||||
|
const formatted = formatGroupForPrompt(
|
||||||
|
{ title: group.groupTitle, noteContent: group.noteContent, todos: group.todos },
|
||||||
|
{ archived: group.archived }
|
||||||
|
);
|
||||||
|
sections.push(`### ${formatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sections.join("\n\n");
|
||||||
|
if (result.length > TOTAL_CHAR_CAP) {
|
||||||
|
result = `${result.slice(0, TOTAL_CHAR_CAP)}\n\n[... additional groups omitted for length ...]`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
42
lib/board.ts
42
lib/board.ts
|
|
@ -1,7 +1,9 @@
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { categoryAccessFilter } from "@/lib/access";
|
||||||
import type { CategoryDTO } from "@/types/board";
|
import type { CategoryDTO } from "@/types/board";
|
||||||
|
import type { AiGroupContext } from "@/types/ai";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches one board's worth of categories/groups/todos, shaped for the
|
* Fetches one board's worth of categories/groups/todos, shaped for the
|
||||||
|
|
@ -50,3 +52,43 @@ export async function getBoard(
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every group the user can see across every board they own -- the Home
|
||||||
|
* board plus every Project (see lib/access.ts's categoryAccessFilter) --
|
||||||
|
* flattened into one list for feeding to the AI as context (see
|
||||||
|
* lib/ai/context.ts). Unlike getBoard, archived groups are deliberately
|
||||||
|
* *not* filtered out: the global chat feature needs to answer historical
|
||||||
|
* questions about finished work, not just describe what's currently on
|
||||||
|
* screen. One query, not one getBoard() call per project.
|
||||||
|
*/
|
||||||
|
export async function getAllGroupsForUser(userId: string): Promise<AiGroupContext[]> {
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
where: categoryAccessFilter(userId),
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
project: { select: { title: true } },
|
||||||
|
groups: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: { todos: { orderBy: { order: "asc" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories.flatMap((category) =>
|
||||||
|
category.groups.map((group) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
groupTitle: group.title,
|
||||||
|
scopeLabel: category.project?.title ?? "Home",
|
||||||
|
categoryName: category.name,
|
||||||
|
archived: group.archivedAt !== null,
|
||||||
|
noteContent: group.noteContent,
|
||||||
|
todos: group.todos.map((todo) => ({
|
||||||
|
title: todo.title,
|
||||||
|
details: todo.details,
|
||||||
|
completed: todo.completed,
|
||||||
|
completedAt: todo.completedAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* One group's worth of data, flattened for feeding to the AI as context --
|
||||||
|
* used by the global chat feature, which needs every group across every
|
||||||
|
* board (Home + all owned Projects) rather than one board's scoped tree
|
||||||
|
* (see CategoryDTO/GroupDTO in types/board.ts). Unlike the board DTOs,
|
||||||
|
* this deliberately includes archived groups, so historical questions
|
||||||
|
* ("what did we do about X") are answerable.
|
||||||
|
*/
|
||||||
|
export interface AiGroupContext {
|
||||||
|
groupId: string;
|
||||||
|
groupTitle: string;
|
||||||
|
// "Home" for the personal board, or the owning Project's title.
|
||||||
|
scopeLabel: string;
|
||||||
|
categoryName: string;
|
||||||
|
archived: boolean;
|
||||||
|
noteContent: string;
|
||||||
|
todos: {
|
||||||
|
title: string;
|
||||||
|
details: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
completedAt: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue