Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' });
Expand Down
53 changes: 49 additions & 4 deletions apps/api/src/routes/ask.ts
Original file line number Diff line number Diff line change
@@ -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<typeof schemas.askSchema>;
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();
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/routes/conversations.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
};
8 changes: 8 additions & 0 deletions apps/api/src/routes/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 33 additions & 2 deletions apps/api/src/services/localAsk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
92 changes: 88 additions & 4 deletions apps/web/src/components/ask/AskClaude.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,13 +36,54 @@ export function AskClaude() {
const { ask, answer, typing, pending, error, stop } = useAsk();
const [messages, setMessages] = useState<Message[]>([]);
const [draft, setDraft] = useState('');
// The persisted thread these messages belong to (null until the first reply).
const [conversationId, setConversationId] = useState<string | null>(null);
// A daily brief this thread follows up on, when arriving via "Discuss".
const [anchorDate, setAnchorDate] = useState<string | undefined>(undefined);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyKey, setHistoryKey] = useState(0);
// The assistant message currently receiving streamed tokens.
const streamingId = useRef<string | null>(null);
const scrollAnchor = useRef<HTMLDivElement>(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;
Expand Down Expand Up @@ -79,18 +124,49 @@ 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 (
// Fill the scroll viewport so the composer pins to the bottom even on a
// short conversation. Mobile subtracts the sticky top bar (h-14 + safe area);
// desktop has no top bar (the rail is the chrome).
<div className="flex flex-col min-h-[calc(100dvh-3.5rem-env(safe-area-inset-top))] md:min-h-[100dvh]">
{/* Control bar — new chat + history */}
<div className="flex items-center justify-end gap-2 pt-4 -mb-2">
{(started || conversationId) && (
<button type="button" className="btn-soft px-3 py-1.5 text-[13px]" onClick={newChat}>
New chat
</button>
)}
<button
type="button"
className="btn-soft px-3 py-1.5 text-[13px]"
onClick={() => setHistoryOpen(true)}
>
History
</button>
</div>

{/* Greeting */}
<header className="pt-9 sm:pt-10 pb-2 animate-fade-rise">
<header className="pt-5 pb-2 animate-fade-rise">
<AskGreeting readiness={readiness} />
{!started && (
{anchorDate && (
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-accent-wash text-accent-deep px-3.5 py-1.5 text-[12.5px] font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
Following up on your {fmtDate(anchorDate, 'EEE, MMM d')} brief
</div>
)}
{!started && !anchorDate && (
<div className="flex flex-wrap gap-2.5 mt-6">
{CHIPS.map((c) => (
<button
Expand Down Expand Up @@ -167,6 +243,14 @@ export function AskClaude() {
</form>
<p className="text-center mt-2.5 text-ink-mute text-[11px]">{GROUNDING_NOTE}</p>
</div>

<ConversationHistory
open={historyOpen}
onClose={() => setHistoryOpen(false)}
onSelect={loadConversation}
activeId={conversationId}
reloadKey={historyKey}
/>
</div>
);
}
Expand Down
Loading
Loading