feat: member portal Sprint 9 — Ask AI (RAG over message archive)
- AskAIQuery model + migration (logs question/answer/citations per member) - AskAIService: RAG pipeline — retrieve via tenant-isolated SearchService (Meili), ground LLM in top-8 snippets with inline [n] citations, degrade gracefully when OPENROUTER_API_KEY absent or no context (snippet fallback) - Shared api/common/llm-client.ts (mirrors worker's OpenRouter client) - SearchModule now exports SearchService - Member endpoints: GET/POST /my/ask (history + ask) - /my/ask page + AskChat client component: question box, answer cards with sources - Ask AI nav item (top of member nav) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Citation {
|
||||
messageId: string;
|
||||
snippet: string;
|
||||
senderName: string;
|
||||
sourceGroupName: string;
|
||||
approvedAt: number;
|
||||
}
|
||||
|
||||
interface QA {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
citations: Citation[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function AskChat({ initialHistory }: { initialHistory: QA[] }) {
|
||||
const [history, setHistory] = useState<QA[]>(initialHistory);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = question.trim();
|
||||
if (!q || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/my/ask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ question: q }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Could not get an answer. Try again.');
|
||||
const data = (await res.json()) as QA;
|
||||
setHistory((h) => [data, ...h]);
|
||||
setQuestion('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form onSubmit={submit} className="space-y-2">
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(e); }}
|
||||
placeholder="Ask anything about your community — events, decisions, who to contact…"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">Answers are grounded in your group's approved messages.</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !question.trim()}
|
||||
className="rounded-lg bg-indigo-600 text-white text-sm font-medium px-5 py-2 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Thinking…' : 'Ask'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</form>
|
||||
|
||||
{history.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
<p>Ask your first question above.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{history.map((qa) => (
|
||||
<div key={qa.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">{qa.question}</p>
|
||||
<p className="text-sm text-gray-700 mt-2 whitespace-pre-line leading-relaxed">{qa.answer}</p>
|
||||
{qa.citations.length > 0 && (
|
||||
<div className="mt-4 border-t border-gray-100 pt-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">Sources</p>
|
||||
{qa.citations.slice(0, 5).map((c, i) => (
|
||||
<div key={c.messageId} className="text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-600">[{i + 1}] {c.sourceGroupName}</span>
|
||||
{' · '}{c.senderName}
|
||||
<p className="text-gray-500 mt-0.5 line-clamp-2">{c.snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
|
||||
import { AskChat } from './AskChat';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Citation {
|
||||
messageId: string;
|
||||
snippet: string;
|
||||
senderName: string;
|
||||
sourceGroupName: string;
|
||||
approvedAt: number;
|
||||
}
|
||||
|
||||
interface QA {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
citations: Citation[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
async function fetchHistory(token: string): Promise<QA[]> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/my/ask`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
}).catch(() => null);
|
||||
if (!res || !res.ok) return [];
|
||||
return (await res.json()) as QA[];
|
||||
}
|
||||
|
||||
export default async function AskPage() {
|
||||
const token = await getMemberToken();
|
||||
if (!token) return null;
|
||||
|
||||
const history = await fetchHistory(token);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Ask AI</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Answers from your community's knowledge.</p>
|
||||
</div>
|
||||
<Link href="/my" className="text-sm text-indigo-600 hover:underline">← Dashboard</Link>
|
||||
</div>
|
||||
|
||||
<AskChat initialHistory={history} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user