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:
YetAnotherSuite Dev 2026-07-20 22:22:54 +02:00
parent 151cb17858
commit 0afbae8944
8 changed files with 1070 additions and 2 deletions

View File

@ -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:*",

View File

@ -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
View 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: [],
};
}

View File

@ -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:*",

View 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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff