diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a643ef9..6f333b6 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -29,6 +29,7 @@ import { registerCompareRoutes } from './routes/compare.js'; import { registerHabitRoutes } from './routes/habits.js'; import { registerInsightsRoutes } from './routes/insights.js'; import { registerAskRoutes } from './routes/ask.js'; +import { registerConversationRoutes } from './routes/conversations.js'; import { registerSyncRoutes } from './routes/sync.js'; import { registerIngestRoutes } from './routes/ingest.js'; import { registerAuthRoutes } from './routes/auth.js'; @@ -86,6 +87,7 @@ async function main() { await app.register(registerHabitRoutes, { prefix: '/api' }); await app.register(registerInsightsRoutes, { prefix: '/api' }); await app.register(registerAskRoutes, { prefix: '/api' }); + await app.register(registerConversationRoutes, { prefix: '/api' }); await app.register(registerSyncRoutes, { prefix: '/api' }); await app.register(registerIngestRoutes, { prefix: '/api' }); await app.register(registerAuthRoutes, { prefix: '/api' }); diff --git a/apps/api/src/routes/ask.ts b/apps/api/src/routes/ask.ts index c72168b..95a614e 100644 --- a/apps/api/src/routes/ask.ts +++ b/apps/api/src/routes/ask.ts @@ -1,25 +1,70 @@ import type { FastifyPluginAsync } from 'fastify'; import { schemas } from '@vcc/shared'; +import { queries } from '@vcc/db'; import type { z } from 'zod'; import { answerQuestion } from '../services/localAsk.js'; +/** Derive a short thread title from the opening question. */ +function titleFrom(question: string): string { + const t = question.trim().replace(/\s+/g, ' '); + return t.length > 70 ? `${t.slice(0, 67)}…` : t; +} + /** * Free-form Q&A answered by the on-box AI CLI (claude -p → codex fallback) over - * the user's recent data. Keeps the SSE contract the web client expects, but the - * CLI is non-streaming so the answer arrives as a single `data:` event. + * the user's recent data. The exchange is PERSISTED to a conversation so the + * user can revisit it and ask follow-ups; the conversation id is returned in the + * `X-Conversation-Id` response header. Keeps the SSE contract the web client + * expects — the CLI is non-streaming so the answer arrives as a single event. */ export const registerAskRoutes: FastifyPluginAsync = async (app) => { app.post('/ask', { schema: { body: schemas.askSchema } }, async (req, reply) => { const body = req.body as z.infer; + const db = req.server.db; + + // Resolve (or open) the thread this question belongs to. + let conversation = body.conversationId ? queries.conversations.meta(db, body.conversationId) : null; + let anchorBrief: string | null = null; + if (!conversation) { + let anchorBriefId: string | null = null; + let anchorDate: string | null = null; + if (body.anchorBriefDate) { + const brief = queries.briefings.latestOfType(db, 'daily', body.anchorBriefDate); + if (brief) { + anchorBriefId = brief.id; + anchorDate = brief.date; + anchorBrief = brief.content; + } + } + conversation = queries.conversations.create(db, { + title: titleFrom(body.question), + anchorBriefId, + anchorDate, + }); + } else if (conversation.anchorBriefId) { + anchorBrief = queries.briefings.byId(db, conversation.anchorBriefId)?.content ?? null; + } + + // Prior turns (before persisting this one) feed the follow-up context. + const history = queries.conversations + .messages(db, conversation.id) + .map((m) => ({ role: m.role, content: m.content })); + queries.conversations.addMessage(db, conversation.id, 'user', body.question); + reply.raw.setHeader('Content-Type', 'text/event-stream'); reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('X-Accel-Buffering', 'no'); + reply.raw.setHeader('X-Conversation-Id', conversation.id); reply.hijack(); try { - const { text, cli } = await answerQuestion(req.server.db, body.question, body.context?.date); - req.log.info({ cli }, 'ask answered'); + const { text, cli } = await answerQuestion(db, body.question, body.context?.date, { + history, + anchorBrief, + }); + queries.conversations.addMessage(db, conversation.id, 'assistant', text); + req.log.info({ cli, conversationId: conversation.id }, 'ask answered'); reply.raw.write(`data: ${JSON.stringify({ text })}\n\n`); reply.raw.write('data: [DONE]\n\n'); reply.raw.end(); diff --git a/apps/api/src/routes/conversations.ts b/apps/api/src/routes/conversations.ts new file mode 100644 index 0000000..ff2d41f --- /dev/null +++ b/apps/api/src/routes/conversations.ts @@ -0,0 +1,25 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { queries } from '@vcc/db'; +import { ok, fail } from '../lib/envelope.js'; + +/** Persisted Ask AI conversations — list, read, delete (for the history drawer). */ +export const registerConversationRoutes: FastifyPluginAsync = async (app) => { + app.get('/conversations', async (req) => { + const { limit } = req.query as { limit?: string }; + const n = Math.min(Math.max(Number(limit) || 30, 1), 100); + return ok(queries.conversations.list(req.server.db, n)); + }); + + app.get('/conversations/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const conv = queries.conversations.get(req.server.db, id); + if (!conv) return reply.status(404).send(fail('conversation not found', 'NOT_FOUND')); + return ok(conv); + }); + + app.delete('/conversations/:id', async (req) => { + const { id } = req.params as { id: string }; + queries.conversations.remove(req.server.db, id); + return ok({ deleted: true }); + }); +}; diff --git a/apps/api/src/routes/insights.ts b/apps/api/src/routes/insights.ts index 557371f..fdd96e8 100644 --- a/apps/api/src/routes/insights.ts +++ b/apps/api/src/routes/insights.ts @@ -22,6 +22,14 @@ export const registerInsightsRoutes: FastifyPluginAsync = async (app) => { return ok(computeWeeklySummary(req.server.db, end || todayIso())); }); + // Recent daily briefs, newest first — includes prior regenerations (each is + // its own row) so the history shows older versions too. + app.get('/insights/briefings', async (req) => { + const { limit } = req.query as { limit?: string }; + const n = Math.min(Math.max(Number(limit) || 30, 1), 100); + return ok(queries.briefings.listRecent(req.server.db, 'daily', n)); + }); + app.get('/insights/briefing/:date', async (req, reply) => { const { date } = req.params as { date: string }; const briefing = queries.briefings.latestOfType(req.server.db, 'daily', date); diff --git a/apps/api/src/services/localAsk.ts b/apps/api/src/services/localAsk.ts index 4ac131c..5b76095 100644 --- a/apps/api/src/services/localAsk.ts +++ b/apps/api/src/services/localAsk.ts @@ -17,12 +17,43 @@ Rules: - Do not use any tools or run commands; answer from the provided data only. - Workouts/runs may be tracked elsewhere (e.g. Strava) and absent here; for training questions reason from recovery + steps.`; +export interface AskOptions { + /** Prior turns in this thread (oldest → newest), for follow-up context. */ + history?: { role: 'user' | 'assistant'; content: string }[]; + /** A daily brief this thread is anchored to — fed as additional context. */ + anchorBrief?: string | null; +} + export async function answerQuestion( db: Database, question: string, date?: string, + opts: AskOptions = {}, ): Promise<{ text: string; cli: 'claude' | 'codex' }> { const context = buildBriefContext(db, date ?? todayIso()); - const prompt = `${ASK_SYSTEM}\n\n## About the user\n${getUserProfile()}\n\n---\nHEALTH DATA:\n${context}\n\n---\nQUESTION: ${question}\n\nAnswer:`; - return runAgent(prompt, { cli: process.env.ASK_CLI as 'claude' | 'codex' | undefined }); + + const parts = [ + ASK_SYSTEM, + `\n\n## About the user\n${getUserProfile()}`, + `\n\n---\nHEALTH DATA:\n${context}`, + ]; + + if (opts.anchorBrief) { + parts.push( + `\n\n---\nEARLIER DAILY BRIEF (the user is asking a follow-up about this brief):\n${opts.anchorBrief}`, + ); + } + + // Keep the last few turns so follow-ups have context without an unbounded prompt. + const history = (opts.history ?? []).slice(-8); + if (history.length) { + const transcript = history + .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`) + .join('\n\n'); + parts.push(`\n\n---\nCONVERSATION SO FAR:\n${transcript}`); + } + + parts.push(`\n\n---\nQUESTION: ${question}\n\nAnswer:`); + + return runAgent(parts.join(''), { cli: process.env.ASK_CLI as 'claude' | 'codex' | undefined }); } diff --git a/apps/web/src/components/ask/AskClaude.tsx b/apps/web/src/components/ask/AskClaude.tsx index ea71100..8f73a55 100644 --- a/apps/web/src/components/ask/AskClaude.tsx +++ b/apps/web/src/components/ask/AskClaude.tsx @@ -1,10 +1,14 @@ -import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import clsx from 'clsx'; import { useAsk } from '../../hooks/useAsk.js'; import { useHealthData } from '../../hooks/useHealthData.js'; import { deriveReadiness } from '../../lib/readiness.js'; +import { getConversation } from '../../lib/api.js'; +import { fmtDate } from '../../lib/formatters.js'; import { Markdown } from '../shared/Markdown.js'; import { AskAvatar, AskGreeting, CHIPS, GROUNDING_NOTE } from './AskShell.js'; +import { ConversationHistory } from './ConversationHistory.js'; import { IconSend } from '../shared/icons.js'; interface Message { @@ -32,13 +36,54 @@ export function AskClaude() { const { ask, answer, typing, pending, error, stop } = useAsk(); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(''); + // The persisted thread these messages belong to (null until the first reply). + const [conversationId, setConversationId] = useState(null); + // A daily brief this thread follows up on, when arriving via "Discuss". + const [anchorDate, setAnchorDate] = useState(undefined); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyKey, setHistoryKey] = useState(0); // The assistant message currently receiving streamed tokens. const streamingId = useRef(null); const scrollAnchor = useRef(null); const inputId = useId(); + const location = useLocation(); const started = messages.length > 0; + // Arriving from a brief's "Discuss" button: start a fresh thread anchored to + // that brief. location.key changes on every navigation so re-clicking works. + const incomingAnchor = (location.state as { anchorBriefDate?: string } | null)?.anchorBriefDate; + useEffect(() => { + if (!incomingAnchor) return; + stop(); + setMessages([]); + setConversationId(null); + setAnchorDate(incomingAnchor); + // Clear nav state so a refresh doesn't re-anchor. + window.history.replaceState({}, ''); + }, [incomingAnchor, location.key, stop]); + + const newChat = useCallback(() => { + stop(); + setMessages([]); + setConversationId(null); + setAnchorDate(undefined); + setDraft(''); + }, [stop]); + + const loadConversation = useCallback( + async (id: string) => { + setHistoryOpen(false); + const conv = await getConversation(id); + stop(); + streamingId.current = null; + setConversationId(conv.id); + setAnchorDate(conv.anchorDate ?? undefined); + setMessages(conv.messages.map((m) => ({ id: m.id, role: m.role, text: m.content }))); + }, + [stop], + ); + // Mirror streamed tokens into the in-flight assistant message as they arrive. useEffect(() => { const id = streamingId.current; @@ -79,7 +124,16 @@ export function AskClaude() { { id: assistantId, role: 'assistant', text: '' }, ]); setDraft(''); - ask(question); + const startingNew = !conversationId; + ask(question, { + conversationId: conversationId ?? undefined, + // Anchor only applies to the first message of a brand-new anchored thread. + anchorBriefDate: conversationId ? undefined : anchorDate, + onConversationId: (id) => { + setConversationId(id); + if (startingNew) setHistoryKey((k) => k + 1); // refresh the drawer list + }, + }); }; return ( @@ -87,10 +141,32 @@ export function AskClaude() { // short conversation. Mobile subtracts the sticky top bar (h-14 + safe area); // desktop has no top bar (the rail is the chrome).
+ {/* Control bar — new chat + history */} +
+ {(started || conversationId) && ( + + )} + +
+ {/* Greeting */} -
+
- {!started && ( + {anchorDate && ( +
+ + Following up on your {fmtDate(anchorDate, 'EEE, MMM d')} brief +
+ )} + {!started && !anchorDate && (
{CHIPS.map((c) => (
+ + setHistoryOpen(false)} + onSelect={loadConversation} + activeId={conversationId} + reloadKey={historyKey} + />
); } diff --git a/apps/web/src/components/ask/ConversationHistory.tsx b/apps/web/src/components/ask/ConversationHistory.tsx new file mode 100644 index 0000000..6248e37 --- /dev/null +++ b/apps/web/src/components/ask/ConversationHistory.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from 'react'; +import type { ConversationSummary } from '@vcc/shared'; +import { listConversations, deleteConversation } from '../../lib/api.js'; +import { fmtDate } from '../../lib/formatters.js'; + +/** Relative-ish timestamp for the history list (sqlite stores tz-less UTC). */ +function whenLabel(iso: string): string { + const t = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(iso) ? iso : `${iso.replace(' ', 'T')}Z`); + if (!Number.isFinite(t)) return ''; + const diff = Date.now() - t; + const min = Math.round(diff / 60000); + if (min < 1) return 'just now'; + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr}h ago`; + return fmtDate(new Date(t).toISOString().slice(0, 10), 'MMM d'); +} + +/** + * Slide-in drawer listing past Ask conversations. Selecting one reopens the + * thread; the trash button deletes it. + */ +export function ConversationHistory({ + open, + onClose, + onSelect, + activeId, + reloadKey, +}: { + open: boolean; + onClose: () => void; + onSelect: (id: string) => void; + activeId: string | null; + /** Bump to force a reload (e.g. after a new conversation is created). */ + reloadKey?: number; +}) { + const [items, setItems] = useState(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setItems(null); + listConversations() + .then((list) => !cancelled && setItems(list)) + .catch(() => !cancelled && setItems([])); + return () => { + cancelled = true; + }; + }, [open, reloadKey]); + + const remove = async (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + setItems((prev) => prev?.filter((c) => c.id !== id) ?? prev); + try { + await deleteConversation(id); + } catch { + /* best effort; list reloads on next open */ + } + }; + + if (!open) return null; + + return ( + <> +
+ + + ); +} diff --git a/apps/web/src/components/dashboard/InsightsPanel.tsx b/apps/web/src/components/dashboard/InsightsPanel.tsx index ce21778..520b336 100644 --- a/apps/web/src/components/dashboard/InsightsPanel.tsx +++ b/apps/web/src/components/dashboard/InsightsPanel.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import type { InsightItem, BriefingRecord } from '@vcc/shared'; -import { apiGet, apiPost } from '../../lib/api.js'; +import { apiGet, apiPost, listBriefings } from '../../lib/api.js'; import { Markdown } from '../shared/Markdown.js'; import { ClaudeSetupPanel } from '../shared/ClaudeSetupPanel.js'; import { useConfigStatus } from '../../hooks/useConfigStatus.js'; @@ -45,9 +45,19 @@ export function InsightsPanel({ const [data, setData] = useState(null); const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); + // Brief history: an older brief being viewed instead of today's, + the list. + const [viewing, setViewing] = useState(null); + const [history, setHistory] = useState(null); + const [histOpen, setHistOpen] = useState(false); const config = useConfigStatus(); + const navigate = useNavigate(); const autoRef = useRef(null); + const openHistory = useCallback(() => { + setHistOpen((o) => !o); + if (history == null) listBriefings().then(setHistory).catch(() => setHistory([])); + }, [history]); + const load = useCallback(() => { apiGet('/api/insights/today') .then(setData) @@ -90,6 +100,9 @@ export function InsightsPanel({ try { const briefing = await apiPost<{ date?: string }, BriefingRecord>('/api/insights/generate', {}); setData((d) => (d ? { ...d, briefing } : d)); + setViewing(null); // snap back to the fresh brief + setHistory(null); // prior brief is retained; refetch the list lazily + setHistOpen(false); } catch (err) { setError((err as Error).message); } finally { @@ -115,14 +128,16 @@ export function InsightsPanel({ }, [autoSummary, data, freshnessKey, generating, generate]); const flags = data?.insights ?? []; + // The brief being shown: an older one the user is browsing, else today's. + const shown = viewing ?? data?.briefing ?? null; // Use the briefing's logical civil date for the header (the day it's about), // and the UTC-normalized createdAt for the time — sqlite stores a tz-less // 'YYYY-MM-DD HH:MM:SS' in UTC, which new Date() would misread as local. const briefDate = fmtDate( - data?.briefing?.date ?? data?.date ?? new Date().toISOString().slice(0, 10), + shown?.date ?? data?.date ?? new Date().toISOString().slice(0, 10), 'EEEE, MMM d', ); - const briefEpoch = data?.briefing ? toEpoch(data.briefing.createdAt) : NaN; + const briefEpoch = shown ? toEpoch(shown.createdAt) : NaN; const briefTime = Number.isFinite(briefEpoch) ? new Date(briefEpoch).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : null; @@ -142,7 +157,7 @@ export function InsightsPanel({
- {config?.claudeApiConfigured && ( + {config?.claudeApiConfigured && !viewing && ( + ) : ( + + Ask + + )} +
+ {/* Past-briefs list */} + {histOpen && ( +
+ {history == null ? ( +
Loading…
+ ) : history.length === 0 ? ( +
No briefs yet.
+ ) : ( +
    + {history.map((b) => { + const t = toEpoch(b.createdAt); + const time = Number.isFinite(t) + ? new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : ''; + const isShown = shown?.id === b.id; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ )} + + {viewing && ( +
+ Viewing an earlier brief + +
+ )} + {/* Flags / things to watch */}
{flags.length === 0 && ( @@ -186,11 +268,11 @@ export function InsightsPanel({ {error &&
{error}
} {/* Brief body */} - {data?.briefing ? ( + {shown ? (
- {data.briefing.content} + {shown.content}
) : config && !config.claudeApiConfigured ? (
diff --git a/apps/web/src/hooks/useAsk.ts b/apps/web/src/hooks/useAsk.ts index 455d971..3415cdd 100644 --- a/apps/web/src/hooks/useAsk.ts +++ b/apps/web/src/hooks/useAsk.ts @@ -33,22 +33,39 @@ export function useAsk() { return () => clearTimeout(id); }, [full, displayed]); - const ask = useCallback((question: string) => { - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - setFull(''); - setDisplayed(''); - setError(null); - setElapsed(0); - startRef.current = Date.now(); - setPending(true); - askStream({ question }, (tok) => setFull((a) => a + tok), ctrl.signal) - .catch((err) => { - if ((err as Error).name !== 'AbortError') setError((err as Error).message); - }) - .finally(() => setPending(false)); - }, []); + const ask = useCallback( + ( + question: string, + opts: { + conversationId?: string; + anchorBriefDate?: string; + onConversationId?: (id: string) => void; + } = {}, + ) => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setFull(''); + setDisplayed(''); + setError(null); + setElapsed(0); + startRef.current = Date.now(); + setPending(true); + askStream( + { question, conversationId: opts.conversationId, anchorBriefDate: opts.anchorBriefDate }, + (tok) => setFull((a) => a + tok), + ctrl.signal, + (meta) => { + if (meta.conversationId) opts.onConversationId?.(meta.conversationId); + }, + ) + .catch((err) => { + if ((err as Error).name !== 'AbortError') setError((err as Error).message); + }) + .finally(() => setPending(false)); + }, + [], + ); const stop = useCallback(() => { abortRef.current?.abort(); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 76e2225..52aa1d9 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,9 @@ -import type { ApiResponse } from '@vcc/shared'; +import type { + ApiResponse, + BriefingRecord, + ConversationSummary, + ConversationWithMessages, +} from '@vcc/shared'; const BASE = import.meta.env.VITE_API_BASE ?? ''; @@ -37,10 +42,28 @@ export async function apiPatch(path: string, body: TBody): Promise< return json.data; } +export async function apiDelete(path: string): Promise { + const res = await fetch(`${BASE}${path}`, { method: 'DELETE', headers: { Accept: 'application/json' } }); + const json = (await res.json()) as ApiResponse; + if (!json.ok) throw new ApiError(json.error.code, json.error.error, json.error.details); +} + +// Ask AI history + daily-brief history. +export const listConversations = () => apiGet('/api/conversations'); +export const getConversation = (id: string) => apiGet(`/api/conversations/${id}`); +export const deleteConversation = (id: string) => apiDelete(`/api/conversations/${id}`); +export const listBriefings = () => apiGet('/api/insights/briefings'); + export function askStream( - body: { question: string; context?: { date?: string; includeBriefing?: boolean } }, + body: { + question: string; + conversationId?: string; + anchorBriefDate?: string; + context?: { date?: string; includeBriefing?: boolean }; + }, onToken: (chunk: string) => void, signal?: AbortSignal, + onMeta?: (meta: { conversationId: string | null }) => void, ): Promise { return new Promise((resolve, reject) => { fetch(`${BASE}/api/ask`, { @@ -51,6 +74,8 @@ export function askStream( }) .then(async (res) => { if (!res.ok || !res.body) throw new Error(`ask HTTP ${res.status}`); + // The server returns the thread id in a header before streaming the body. + onMeta?.({ conversationId: res.headers.get('X-Conversation-Id') }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ''; diff --git a/packages/db/src/migrations/009_conversations.sql b/packages/db/src/migrations/009_conversations.sql new file mode 100644 index 0000000..1a370cc --- /dev/null +++ b/packages/db/src/migrations/009_conversations.sql @@ -0,0 +1,23 @@ +-- Persisted Ask AI conversations so the user can revisit prior chats and ask +-- follow-ups. A conversation may be ANCHORED to a daily brief (anchor_brief_id) +-- when it started as "discuss this brief" — the brief is then fed as context. +CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + anchor_brief_id TEXT, -- briefings.id this thread follows up on (nullable) + anchor_date TEXT, -- the anchored brief's civil date, for display + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS conversation_messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), + content TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_conv_updated ON conversations(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_conv_msg ON conversation_messages(conversation_id, created_at); diff --git a/packages/db/src/queries/briefings.ts b/packages/db/src/queries/briefings.ts index e5b19f4..cd17bd5 100644 --- a/packages/db/src/queries/briefings.ts +++ b/packages/db/src/queries/briefings.ts @@ -47,6 +47,22 @@ export function latestBefore(db: Database, type: BriefingType, date: string): Br return row ? toBriefing(row) : null; } +export function byId(db: Database, id: string): BriefingRecord | null { + const row = db.prepare('SELECT * FROM briefings WHERE id = ?').get(id) as BriefingRow | undefined; + return row ? toBriefing(row) : null; +} + +/** + * Recent briefings of a type, newest first — INCLUDING multiple per day (each + * regenerate is its own row), so the history shows prior versions too. + */ +export function listRecent(db: Database, type: BriefingType, limit = 30): BriefingRecord[] { + const rows = db + .prepare('SELECT * FROM briefings WHERE type = ? ORDER BY date DESC, created_at DESC LIMIT ?') + .all(type, limit) as BriefingRow[]; + return rows.map(toBriefing); +} + export function store( db: Database, input: Omit, diff --git a/packages/db/src/queries/conversations.ts b/packages/db/src/queries/conversations.ts new file mode 100644 index 0000000..08db40b --- /dev/null +++ b/packages/db/src/queries/conversations.ts @@ -0,0 +1,123 @@ +import type { Database } from 'better-sqlite3'; +import type { + Conversation, + ConversationMessage, + ConversationSummary, + ConversationWithMessages, +} from '@vcc/shared'; +import { randomUUID } from 'node:crypto'; + +interface ConvRow { + id: string; + title: string; + anchor_brief_id: string | null; + anchor_date: string | null; + created_at: string; + updated_at: string; +} + +interface MsgRow { + id: string; + conversation_id: string; + role: 'user' | 'assistant'; + content: string; + created_at: string; +} + +function toConversation(r: ConvRow): Conversation { + return { + id: r.id, + title: r.title, + anchorBriefId: r.anchor_brief_id, + anchorDate: r.anchor_date, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +function toMessage(r: MsgRow): ConversationMessage { + return { + id: r.id, + conversationId: r.conversation_id, + role: r.role, + content: r.content, + createdAt: r.created_at, + }; +} + +export function create( + db: Database, + input: { title: string; anchorBriefId?: string | null; anchorDate?: string | null }, +): Conversation { + const id = `conv_${randomUUID()}`; + db.prepare( + `INSERT INTO conversations (id, title, anchor_brief_id, anchor_date, created_at, updated_at) + VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))`, + ).run(id, input.title.slice(0, 120), input.anchorBriefId ?? null, input.anchorDate ?? null); + return meta(db, id)!; +} + +export function meta(db: Database, id: string): Conversation | null { + const row = db.prepare('SELECT * FROM conversations WHERE id = ?').get(id) as ConvRow | undefined; + return row ? toConversation(row) : null; +} + +export function addMessage( + db: Database, + conversationId: string, + role: 'user' | 'assistant', + content: string, +): ConversationMessage { + const id = `msg_${randomUUID()}`; + db.transaction(() => { + db.prepare( + `INSERT INTO conversation_messages (id, conversation_id, role, content, created_at) + VALUES (?, ?, ?, ?, datetime('now'))`, + ).run(id, conversationId, role, content); + db.prepare(`UPDATE conversations SET updated_at = datetime('now') WHERE id = ?`).run(conversationId); + })(); + const row = db.prepare('SELECT * FROM conversation_messages WHERE id = ?').get(id) as MsgRow; + return toMessage(row); +} + +export function messages(db: Database, conversationId: string): ConversationMessage[] { + const rows = db + .prepare('SELECT * FROM conversation_messages WHERE conversation_id = ? ORDER BY created_at ASC, id ASC') + .all(conversationId) as MsgRow[]; + return rows.map(toMessage); +} + +export function get(db: Database, id: string): ConversationWithMessages | null { + const c = meta(db, id); + if (!c) return null; + return { ...c, messages: messages(db, id) }; +} + +export function list(db: Database, limit = 30): ConversationSummary[] { + const rows = db + .prepare( + `SELECT c.id, c.title, c.anchor_date, c.updated_at, + (SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id) AS message_count + FROM conversations c + ORDER BY c.updated_at DESC + LIMIT ?`, + ) + .all(limit) as Array<{ + id: string; + title: string; + anchor_date: string | null; + updated_at: string; + message_count: number; + }>; + return rows.map((r) => ({ + id: r.id, + title: r.title, + anchorDate: r.anchor_date, + updatedAt: r.updated_at, + messageCount: r.message_count, + })); +} + +export function remove(db: Database, id: string): void { + db.prepare('DELETE FROM conversations WHERE id = ?').run(id); +} diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts index 3dc2028..a79c21c 100644 --- a/packages/db/src/queries/index.ts +++ b/packages/db/src/queries/index.ts @@ -3,6 +3,7 @@ export * as sleep from './sleep.js'; export * as workouts from './workouts.js'; export * as habits from './habits.js'; export * as briefings from './briefings.js'; +export * as conversations from './conversations.js'; export * as context from './context.js'; export * as syncLog from './syncLog.js'; export * as settings from './settings.js'; diff --git a/packages/shared/src/schemas/ask.ts b/packages/shared/src/schemas/ask.ts index a324ac9..64a96dd 100644 --- a/packages/shared/src/schemas/ask.ts +++ b/packages/shared/src/schemas/ask.ts @@ -2,6 +2,10 @@ import { z } from 'zod'; export const askSchema = z.object({ question: z.string().min(3).max(2000), + /** Continue an existing thread; omit to start a new one. */ + conversationId: z.string().optional(), + /** Start a new thread anchored to this daily brief (a "discuss this brief" follow-up). */ + anchorBriefDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), context: z .object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), diff --git a/packages/shared/src/types/briefings.ts b/packages/shared/src/types/briefings.ts index 9e9d525..faa60ef 100644 --- a/packages/shared/src/types/briefings.ts +++ b/packages/shared/src/types/briefings.ts @@ -38,6 +38,39 @@ export interface WeeklySummary { worstSleep: { date: string; hours: number } | null; } +/** A persisted Ask AI conversation thread. */ +export interface Conversation { + id: string; + title: string; + /** briefings.id this thread follows up on, or null for a free-form chat. */ + anchorBriefId: string | null; + /** The anchored brief's civil date, for display. */ + anchorDate: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ConversationMessage { + id: string; + conversationId: string; + role: 'user' | 'assistant'; + content: string; + createdAt: string; +} + +/** Lightweight row for the history drawer. */ +export interface ConversationSummary { + id: string; + title: string; + anchorDate: string | null; + updatedAt: string; + messageCount: number; +} + +export interface ConversationWithMessages extends Conversation { + messages: ConversationMessage[]; +} + export interface InsightItem { id: string; severity: 'green' | 'amber' | 'red' | 'blue';