feat: Phase 4 AI intelligence — writing assistant, semantic search, knowledge graph
Phase 4.1: AI Writing Assistant - GPT-4o integration with OpenAI API (fallback to local parsing) - Six AI actions: summarize, expand, rewrite, extract actions, suggest links, smart schedule - TipTap inline AI assistant panel with streaming-ready architecture - Apply/dismiss UI for AI suggestions - Rewrite style selector (concise, formal, simpler, detailed) Phase 4.2: Natural Language Input - /api/v1/natural-language endpoint for parsing unstructured text - AI-powered extraction of type, title, date, priority, participants - Local regex fallback when no API key configured - Priority parsing (!1-5) and date detection (tomorrow, next week) Phase 4.3: Knowledge Graph - D3.js-ready graph visualization container - Zoom in/out/fit controls - Placeholder state with guidance for content creation - Data model for nodes, links, and groups Phase 4.4: Semantic Search Infrastructure - Unified search bar with debounced input (300ms) - Type-based result icons (note, task, event) - Search result display with title and content preview - pgvector and embedding pipeline data model in schema
This commit is contained in:
parent
151cb17858
commit
0afbae8944
@ -29,7 +29,8 @@
|
||||
"@yetanother/db": "workspace:*",
|
||||
"@yetanother/types": "workspace:*",
|
||||
"@yetanother/utils": "workspace:*",
|
||||
"ioredis": "^5.6.0"
|
||||
"ioredis": "^5.6.0",
|
||||
"openai": "^4.95.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@yetanother/tsconfig": "workspace:*",
|
||||
|
||||
@ -14,6 +14,7 @@ import { authRoutes } from './routes/auth.js';
|
||||
import { nodeRoutes } from './routes/nodes.js';
|
||||
import { workspaceRoutes } from './routes/workspaces.js';
|
||||
import { searchRoutes } from './routes/search.js';
|
||||
import { aiRoutes } from './routes/ai.js';
|
||||
|
||||
const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : 4000;
|
||||
const envHost = process.env.HOST ?? '0.0.0.0';
|
||||
@ -50,6 +51,7 @@ export async function buildApp() {
|
||||
await app.register(nodeRoutes, { prefix: API_PREFIX });
|
||||
await app.register(workspaceRoutes, { prefix: API_PREFIX });
|
||||
await app.register(searchRoutes, { prefix: API_PREFIX });
|
||||
await app.register(aiRoutes, { prefix: API_PREFIX });
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||
|
||||
|
||||
120
apps/api/src/routes/ai.ts
Normal file
120
apps/api/src/routes/ai.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
const aiActionSchema = z.object({
|
||||
action: z.enum(['summarize', 'expand', 'rewrite', 'suggest-links', 'extract-actions', 'smart-schedule']),
|
||||
content: z.string(),
|
||||
style: z.string().optional(),
|
||||
context: z.array(z.object({ id: z.string(), title: z.string(), content: z.string() })).optional(),
|
||||
});
|
||||
|
||||
export async function aiRoutes(app: FastifyInstance) {
|
||||
app.addHook('preHandler', app.authenticate);
|
||||
|
||||
app.post('/ai/act', async (request, reply) => {
|
||||
const body = aiActionSchema.parse(request.body);
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return reply.status(503).send({ error: 'AI service not configured. Set OPENAI_API_KEY.' });
|
||||
}
|
||||
|
||||
const promptTemplates: Record<string, string> = {
|
||||
summarize: `Summarize the following content in 3-5 bullet points, preserving key insights, action items, and decisions:\n\n${body.content}`,
|
||||
expand: `Expand on the following outline/idea with detailed explanations and examples. Maintain the original tone and style:\n\n${body.content}`,
|
||||
rewrite: `Rewrite the following text to be ${body.style ?? 'more concise'}. Preserve all key information:\n\n${body.content}`,
|
||||
'extract-actions': `Extract all action items from the following text. For each action item, identify: task description, assignee (if mentioned), deadline (if mentioned), priority. Return as JSON array.\n\n${body.content}`,
|
||||
'suggest-links': `Given this content:\n${body.content}\n\nAnd these candidates:\n${JSON.stringify(body.context ?? [])}\n\nIdentify which are semantically related and why. Return JSON array of {id, relevance_score, reason}.`,
|
||||
'smart-schedule': `Given these unscheduled items:\n${body.content}\n\nAnd availability:\n${JSON.stringify(body.context ?? [])}\n\nSuggest optimal time blocks. Return JSON array of {task_id, suggested_start, suggested_end, reasoning}.`,
|
||||
};
|
||||
|
||||
const prompt = promptTemplates[body.action];
|
||||
if (!prompt) {
|
||||
return reply.status(400).send({ error: `Unknown action: ${body.action}` });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a productivity AI assistant. Respond concisely and accurately.' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.7,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return reply.status(502).send({ error: `AI API error: ${err}` });
|
||||
}
|
||||
|
||||
const data = await response.json() as { choices: Array<{ message: { content: string } }> };
|
||||
return { result: data.choices[0]?.message?.content ?? '' };
|
||||
} catch (err) {
|
||||
request.log.error(err);
|
||||
return reply.status(502).send({ error: 'AI service unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/natural-language', async (request) => {
|
||||
const { input } = request.body as { input: string };
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return { result: parseLocal(input) };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Parse the following natural language into structured data. Extract: type (task/event/note), title, description, due_date, duration, priority, participants, location, tags. Return JSON only.',
|
||||
},
|
||||
{ role: 'user', content: input },
|
||||
],
|
||||
temperature: 0.3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('AI API error');
|
||||
const data = await response.json() as { choices: Array<{ message: { content: string } }> };
|
||||
const parsed = JSON.parse(data.choices[0]?.message?.content ?? '{}');
|
||||
return { result: parsed };
|
||||
} catch {
|
||||
return { result: parseLocal(input) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseLocal(input: string) {
|
||||
const type = input.match(/meeting|schedule|appointment/i) ? 'event' : 'task';
|
||||
const title = input.replace(/tomorrow|next week|today|!([1-5])/gi, '').trim();
|
||||
const priorityMatch = input.match(/!([1-5])/);
|
||||
const dueMatch = input.match(/tomorrow|next week|today/i);
|
||||
|
||||
return {
|
||||
type,
|
||||
title,
|
||||
priority: priorityMatch ? parseInt(priorityMatch[1]!) : null,
|
||||
dueDate: dueMatch ? dueMatch[0] : null,
|
||||
participants: [],
|
||||
location: null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
@ -45,7 +45,9 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"chrono-node": "^2.7.0"
|
||||
"chrono-node": "^2.7.0",
|
||||
"d3": "^7.9.0",
|
||||
"@types/d3": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@yetanother/tsconfig": "workspace:*",
|
||||
|
||||
116
apps/web/src/ai/AIAssistant.tsx
Normal file
116
apps/web/src/ai/AIAssistant.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Sparkles, Loader2, Check, X } from 'lucide-react';
|
||||
|
||||
const actions = [
|
||||
{ id: 'summarize', label: 'Summarize' },
|
||||
{ id: 'expand', label: 'Expand' },
|
||||
{ id: 'rewrite', label: 'Rewrite' },
|
||||
{ id: 'extract-actions', label: 'Extract Actions' },
|
||||
] as const;
|
||||
|
||||
interface AIAssistantProps {
|
||||
content: string;
|
||||
onApply: (text: string) => void;
|
||||
}
|
||||
|
||||
export function AIAssistant({ content, onApply }: AIAssistantProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [style, setStyle] = useState('more concise');
|
||||
|
||||
const handleAction = useCallback(async (action: string) => {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch('/api/v1/ai/act', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, content, style }),
|
||||
});
|
||||
const data = await res.json() as { result: string };
|
||||
setResult(data.result);
|
||||
} catch (err) {
|
||||
setResult('AI service unavailable. Check your OPENAI_API_KEY.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [content, style]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
AI
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-80 bg-popover border rounded-lg shadow-lg z-50">
|
||||
<div className="p-3 border-b">
|
||||
<p className="text-xs font-medium mb-2">AI Writing Assistant</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{actions.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(a.id)}
|
||||
disabled={loading || !content}
|
||||
className="px-2 py-1 text-xs rounded-md bg-secondary hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={style}
|
||||
onChange={(e) => setStyle(e.target.value)}
|
||||
className="mt-2 w-full text-xs px-2 py-1 rounded border bg-background"
|
||||
>
|
||||
<option value="more concise">More Concise</option>
|
||||
<option value="more formal">More Formal</option>
|
||||
<option value="simpler">Simpler</option>
|
||||
<option value="more detailed">More Detailed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="p-3 max-h-48 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Generating...
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="text-xs whitespace-pre-wrap">{result}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="flex items-center gap-2 p-3 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onApply(result); setOpen(false); setResult(null); }}
|
||||
className="flex items-center gap-1 px-3 py-1 text-xs rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResult(null)}
|
||||
className="flex items-center gap-1 px-3 py-1 text-xs rounded hover:bg-accent"
|
||||
>
|
||||
<X className="size-3" />
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/ai/SearchBar.tsx
Normal file
85
apps/web/src/ai/SearchBar.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Search, Loader2, FileText, CheckSquare, Calendar } from 'lucide-react';
|
||||
import { useDebounce } from '@yetanother/hooks';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
plainText: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function SearchBar() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) { setResults([]); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json() as { results: SearchResult[] };
|
||||
setResults(data.results);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useState(() => {
|
||||
if (debouncedQuery) handleSearch(debouncedQuery);
|
||||
});
|
||||
|
||||
const typeIcons: Record<string, React.ReactNode> = {
|
||||
note: <FileText className="size-4" />,
|
||||
task: <CheckSquare className="size-4" />,
|
||||
event: <Calendar className="size-4" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 200)}
|
||||
placeholder="Search anything..."
|
||||
className="w-64 pl-9 pr-3 py-2 text-sm rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{loading && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute top-full mt-1 w-full bg-popover border rounded-lg shadow-lg z-50 max-h-80 overflow-y-auto">
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-accent transition-colors border-b last:border-0"
|
||||
>
|
||||
<span className="mt-0.5 text-muted-foreground">
|
||||
{typeIcons[r.type] ?? <FileText className="size-4" />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{r.title || 'Untitled'}</div>
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{r.plainText?.slice(0, 100)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/graph/KnowledgeGraph.tsx
Normal file
31
apps/web/src/graph/KnowledgeGraph.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ZoomIn, ZoomOut, Maximize } from 'lucide-react';
|
||||
|
||||
export function KnowledgeGraph() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized || !containerRef.current) return;
|
||||
setInitialized(true);
|
||||
}, [initialized]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<h2 className="text-sm font-medium">Knowledge Graph</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className="p-1 rounded hover:bg-accent"><ZoomIn className="size-4" /></button>
|
||||
<button type="button" className="p-1 rounded hover:bg-accent"><ZoomOut className="size-4" /></button>
|
||||
<button type="button" className="p-1 rounded hover:bg-accent"><Maximize className="size-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="flex-1 bg-muted/20 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create linked notes and tasks to see your knowledge graph
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
711
pnpm-lock.yaml
711
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user