feat: major platform expansion — Brain service, RSS reader, iOS app, AI assistants, Firefox extension
Brain Service: - Playwright stealth crawler replacing browserless (og:image, Readability, Reddit JSON API) - AI classification with tag definitions and folder assignment - YouTube video download via yt-dlp - Karakeep migration complete (96 items) - Taxonomy management (folders with icons/colors, tags) - Discovery shuffle, sort options, search (Meilisearch + pgvector) - Item tag/folder editing, card color accents RSS Reader Service: - Custom FastAPI reader replacing Miniflux - Feed management (add/delete/refresh), category support - Full article extraction via Readability - Background content fetching for new entries - Mark all read with confirmation - Infinite scroll, retention cleanup (30/60 day) - 17 feeds migrated from Miniflux iOS App (SwiftUI): - Native iOS 17+ app with @Observable architecture - Cookie-based auth, configurable gateway URL - Dashboard with custom background photo + frosted glass widgets - Full fitness module (today/templates/goals/food library) - AI assistant chat (fitness + brain, raw JSON state management) - 120fps ProMotion support AI Assistants (Gateway): - Unified dispatcher with fitness/brain domain detection - Fitness: natural language food logging, photo analysis, multi-item splitting - Brain: save/append/update/delete notes, search & answer, undo support - Madiha user gets fitness-only (brain disabled) Firefox Extension: - One-click save to Brain from any page - Login with platform credentials - Right-click context menu (save page/link/image) - Notes field for URL saves - Signed and published on AMO Other: - Reader bookmark button routes to Brain (was Karakeep) - Fitness food library with "Add" button + add-to-meal popup - Kindle send file size check (25MB SMTP2GO limit) - Atelier UI as default (useAtelierShell=true) - Mobile upload box in nav drawer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
140
frontend-v2/src/routes/assistant/+server.ts
Normal file
140
frontend-v2/src/routes/assistant/+server.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
type ChatRole = 'user' | 'assistant';
|
||||
|
||||
type ChatMessage = {
|
||||
role?: ChatRole;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type UnifiedState = {
|
||||
activeDomain?: 'fitness' | 'brain';
|
||||
fitnessState?: Record<string, unknown>;
|
||||
brainState?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function recentMessages(messages: unknown): Array<{ role: ChatRole; content: string }> {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
return messages
|
||||
.filter((message) => !!message && typeof message === 'object')
|
||||
.map((message) => {
|
||||
const item = message as ChatMessage;
|
||||
return {
|
||||
role: item.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: typeof item.content === 'string' ? item.content : ''
|
||||
};
|
||||
})
|
||||
.filter((message) => message.content.trim())
|
||||
.slice(-16);
|
||||
}
|
||||
|
||||
function lastUserMessage(messages: Array<{ role: ChatRole; content: string }>): string {
|
||||
return [...messages].reverse().find((message) => message.role === 'user')?.content?.trim() || '';
|
||||
}
|
||||
|
||||
function isFitnessIntent(text: string): boolean {
|
||||
return /\b(calories?|protein|carbs?|fat|sugar|fiber|breakfast|lunch|dinner|snack|meal|food|ate|eaten|log|track|entries|macros?)\b/i.test(text)
|
||||
|| /\bfor (breakfast|lunch|dinner|snack)\b/i.test(text)
|
||||
|| /\bhow many calories do i have left\b/i.test(text);
|
||||
}
|
||||
|
||||
function isBrainIntent(text: string): boolean {
|
||||
return /\b(note|notes|brain|remember|save this|save that|what do i have|what have i saved|find my|delete (?:that|this|note|item)|update (?:that|this|note)|from my notes)\b/i.test(text);
|
||||
}
|
||||
|
||||
function detectDomain(
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
state: UnifiedState,
|
||||
imageDataUrl?: string | null
|
||||
): 'fitness' | 'brain' {
|
||||
const text = lastUserMessage(messages);
|
||||
if (imageDataUrl) return 'fitness';
|
||||
if (!text) return state.activeDomain || 'brain';
|
||||
|
||||
const fitness = isFitnessIntent(text);
|
||||
const brain = isBrainIntent(text);
|
||||
|
||||
if (fitness && !brain) return 'fitness';
|
||||
if (brain && !fitness) return 'brain';
|
||||
|
||||
if (state.activeDomain === 'fitness' && !brain) return 'fitness';
|
||||
if (state.activeDomain === 'brain' && !fitness) return 'brain';
|
||||
|
||||
return fitness ? 'fitness' : 'brain';
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, fetch, cookies }) => {
|
||||
if (!cookies.get('platform_session')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { messages = [], state = {}, imageDataUrl = null, entryDate = null, action = 'chat', allowBrain = true } = await request
|
||||
.json()
|
||||
.catch(() => ({}));
|
||||
|
||||
let brainEnabled = !!allowBrain;
|
||||
try {
|
||||
const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
|
||||
const session = cookies.get('platform_session');
|
||||
if (session) {
|
||||
const auth = await fetch(`${gatewayUrl}/api/auth/me`, {
|
||||
headers: { Cookie: `platform_session=${session}` }
|
||||
});
|
||||
if (auth.ok) {
|
||||
const data = await auth.json().catch(() => null);
|
||||
if (data?.authenticated && data?.user?.username === 'madiha') {
|
||||
brainEnabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// keep requested allowBrain fallback
|
||||
}
|
||||
|
||||
const chat = recentMessages(messages);
|
||||
const unifiedState: UnifiedState = state && typeof state === 'object' ? state : {};
|
||||
const domain = brainEnabled ? detectDomain(chat, unifiedState, imageDataUrl) : 'fitness';
|
||||
|
||||
const response = await fetch(domain === 'fitness' ? '/assistant/fitness' : '/assistant/brain', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(
|
||||
domain === 'fitness'
|
||||
? {
|
||||
action,
|
||||
messages,
|
||||
draft: unifiedState.fitnessState && 'draft' in unifiedState.fitnessState ? unifiedState.fitnessState.draft : null,
|
||||
drafts: unifiedState.fitnessState && 'drafts' in unifiedState.fitnessState ? unifiedState.fitnessState.drafts : [],
|
||||
entryDate,
|
||||
imageDataUrl
|
||||
}
|
||||
: {
|
||||
messages,
|
||||
state: unifiedState.brainState || {}
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
return json(body, { status: response.status });
|
||||
}
|
||||
|
||||
return json({
|
||||
...body,
|
||||
domain,
|
||||
state: {
|
||||
activeDomain: domain,
|
||||
fitnessState:
|
||||
domain === 'fitness'
|
||||
? {
|
||||
draft: body?.draft ?? null,
|
||||
drafts: Array.isArray(body?.drafts) ? body.drafts : []
|
||||
}
|
||||
: unifiedState.fitnessState || {},
|
||||
brainState: domain === 'brain' ? body?.state || {} : unifiedState.brainState || {}
|
||||
}
|
||||
});
|
||||
};
|
||||
799
frontend-v2/src/routes/assistant/brain/+server.ts
Normal file
799
frontend-v2/src/routes/assistant/brain/+server.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
type ChatRole = 'user' | 'assistant';
|
||||
|
||||
type ChatMessage = {
|
||||
role?: ChatRole;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type BrainItem = {
|
||||
id: string;
|
||||
type: string;
|
||||
title?: string | null;
|
||||
url?: string | null;
|
||||
raw_content?: string | null;
|
||||
extracted_text?: string | null;
|
||||
folder?: string | null;
|
||||
tags?: string[] | null;
|
||||
summary?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type BrainAddition = {
|
||||
id: string;
|
||||
item_id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type AssistantState = {
|
||||
lastMutation?: {
|
||||
type: 'append' | 'create' | 'update';
|
||||
itemId: string;
|
||||
itemTitle: string;
|
||||
additionId?: string;
|
||||
content: string;
|
||||
createdItemId?: string;
|
||||
previousRawContent?: string;
|
||||
};
|
||||
pendingDelete?: {
|
||||
itemId: string;
|
||||
itemTitle: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SourceLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type SearchQueryDecision = {
|
||||
queries?: string[];
|
||||
};
|
||||
|
||||
function recentMessages(messages: unknown): Array<{ role: ChatRole; content: string }> {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
return messages
|
||||
.filter((m) => !!m && typeof m === 'object')
|
||||
.map((m) => {
|
||||
const message = m as ChatMessage;
|
||||
return {
|
||||
role: (message.role === 'assistant' ? 'assistant' : 'user') as ChatRole,
|
||||
content: typeof message.content === 'string' ? message.content.slice(0, 4000) : ''
|
||||
};
|
||||
})
|
||||
.filter((m) => m.content.trim())
|
||||
.slice(-12);
|
||||
}
|
||||
|
||||
function lastUserMessage(messages: Array<{ role: ChatRole; content: string }>): string {
|
||||
return [...messages].reverse().find((message) => message.role === 'user')?.content?.trim() || '';
|
||||
}
|
||||
|
||||
function toSource(item: BrainItem): SourceLink {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title || 'Untitled',
|
||||
type: item.type,
|
||||
href: `/brain?item=${item.id}`
|
||||
};
|
||||
}
|
||||
|
||||
function isConfirmation(text: string): boolean {
|
||||
const clean = text.trim().toLowerCase();
|
||||
return [
|
||||
'yes',
|
||||
'yes delete it',
|
||||
'delete it',
|
||||
'confirm',
|
||||
'yes do it',
|
||||
'do it',
|
||||
'go ahead'
|
||||
].includes(clean);
|
||||
}
|
||||
|
||||
function isUndo(text: string): boolean {
|
||||
return /^(undo|undo last change|undo that|revert that)$/i.test(text.trim());
|
||||
}
|
||||
|
||||
function wantsNewNoteInstead(text: string): boolean {
|
||||
return /create (?:a )?new note/i.test(text) || /make (?:that|it) a new note/i.test(text);
|
||||
}
|
||||
|
||||
function moveTargetFromText(text: string): string | null {
|
||||
const match = text.match(/(?:add|move)\s+(?:that|it)\s+to\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function wantsDelete(text: string): boolean {
|
||||
return /\bdelete\b/i.test(text) && /\b(note|item|that)\b/i.test(text);
|
||||
}
|
||||
|
||||
function isExplicitUpdateRequest(text: string): boolean {
|
||||
return /\b(update|edit|change|replace|correct|set)\b/i.test(text);
|
||||
}
|
||||
|
||||
function isListingIntent(text: string): boolean {
|
||||
return /\b(what|which|show|list)\b/i.test(text)
|
||||
&& /\b(do i have|have i saved|saved|notes|items|books?|pdfs?|links?|documents?|files?)\b/i.test(text);
|
||||
}
|
||||
|
||||
function buildCandidateSummary(items: BrainItem[]): string {
|
||||
if (!items.length) return 'No candidates found.';
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const snippet = (item.raw_content || item.extracted_text || item.summary || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 220);
|
||||
return `${index + 1}. id=${item.id}
|
||||
title=${item.title || 'Untitled'}
|
||||
type=${item.type}
|
||||
folder=${item.folder || ''}
|
||||
tags=${(item.tags || []).join(', ')}
|
||||
snippet=${snippet}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
async function brainSearch(fetchFn: typeof fetch, q: string, limit = 5): Promise<BrainItem[]> {
|
||||
const response = await fetchFn('/api/brain/search/hybrid', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ q, limit })
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const body = await response.json().catch(() => ({}));
|
||||
return Array.isArray(body?.items) ? body.items : [];
|
||||
}
|
||||
|
||||
function normalizeSearchSeed(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/^what\s+(books?|notes?|pdfs?|links?|documents?|files?)\s+do i have saved\s*/i, '$1 ')
|
||||
.replace(/^show me\s+(all\s+)?(books?|notes?|pdfs?|links?|documents?|files?)\s*/i, '$2 ')
|
||||
.replace(/^list\s+(my\s+)?(books?|notes?|pdfs?|links?|documents?|files?)\s*/i, '$2 ')
|
||||
.replace(/^add note\s+/i, '')
|
||||
.replace(/^add\s+/i, '')
|
||||
.replace(/^save\s+(?:this|that)\s+/i, '')
|
||||
.replace(/^what do i have about\s+/i, '')
|
||||
.replace(/^what have i saved about\s+/i, '')
|
||||
.replace(/^find\s+/i, '')
|
||||
.replace(/^search for\s+/i, '')
|
||||
.replace(/^answer\s+/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function deriveSearchQueries(
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
userText: string
|
||||
): Promise<string[]> {
|
||||
const normalized = normalizeSearchSeed(userText);
|
||||
const fallback = [normalized || userText.trim()].filter(Boolean);
|
||||
if (!env.OPENAI_API_KEY) return fallback;
|
||||
|
||||
const systemPrompt = `You extract concise retrieval queries for a personal knowledge base.
|
||||
|
||||
Return ONLY JSON:
|
||||
{
|
||||
"queries": ["query one", "query two"]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Return 1 to 3 short search queries.
|
||||
- Focus on the underlying topic, not chat filler.
|
||||
- For "what do I have about X", include just X.
|
||||
- For advice/tips, you may infer a likely broader topic if strongly implied.
|
||||
- Keep each query short, usually 1 to 5 words.
|
||||
- Do not include punctuation-heavy sentences.
|
||||
- Do not include explanatory text.`;
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: env.OPENAI_MODEL || 'gpt-5.2',
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.1,
|
||||
max_completion_tokens: 200,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.slice(-6),
|
||||
{ role: 'user', content: `Search intent: ${userText}` }
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) return fallback;
|
||||
const raw = await response.json().catch(() => null);
|
||||
const content = raw?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') return fallback;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content) as SearchQueryDecision;
|
||||
const queries = Array.isArray(parsed.queries)
|
||||
? parsed.queries.map((query) => String(query || '').trim()).filter(Boolean).slice(0, 3)
|
||||
: [];
|
||||
return queries.length ? queries : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectCandidates(
|
||||
fetchFn: typeof fetch,
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
userText: string,
|
||||
limit = 8
|
||||
): Promise<BrainItem[]> {
|
||||
const queries = await deriveSearchQueries(messages, userText);
|
||||
const merged = new Map<string, BrainItem>();
|
||||
|
||||
for (const query of queries) {
|
||||
const items = await brainSearch(fetchFn, query, limit);
|
||||
for (const item of items) {
|
||||
if (!merged.has(item.id)) merged.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
if (!merged.size) {
|
||||
for (const item of await brainSearch(fetchFn, userText, limit)) {
|
||||
if (!merged.has(item.id)) merged.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].slice(0, limit);
|
||||
}
|
||||
|
||||
async function createBrainNote(fetchFn: typeof fetch, content: string, title?: string) {
|
||||
const response = await fetchFn('/api/brain/items', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'note',
|
||||
raw_content: content,
|
||||
title: title?.trim() || undefined
|
||||
})
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: await response.json().catch(() => ({}))
|
||||
};
|
||||
}
|
||||
|
||||
async function updateBrainItem(fetchFn: typeof fetch, itemId: string, body: { raw_content?: string; title?: string }) {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: await response.json().catch(() => ({}))
|
||||
};
|
||||
}
|
||||
|
||||
async function appendToItem(fetchFn: typeof fetch, itemId: string, content: string) {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}/additions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ content, source: 'assistant', kind: 'append' })
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: await response.json().catch(() => ({}))
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteAddition(fetchFn: typeof fetch, itemId: string, additionId: string) {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}/additions/${additionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
async function deleteItem(fetchFn: typeof fetch, itemId: string) {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}`, { method: 'DELETE' });
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
async function getItem(fetchFn: typeof fetch, itemId: string): Promise<BrainItem | null> {
|
||||
const response = await fetchFn(`/api/brain/items/${itemId}`);
|
||||
if (!response.ok) return null;
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
async function decideAction(
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
candidates: BrainItem[],
|
||||
state: AssistantState,
|
||||
listingIntent = false
|
||||
) {
|
||||
const systemPrompt = `You are the Brain assistant inside a personal app.
|
||||
|
||||
You help the user save notes naturally, append thoughts to existing items, answer questions from saved notes, and choose whether to create a new note.
|
||||
|
||||
Return ONLY JSON with this shape:
|
||||
{
|
||||
"action": "append_existing" | "create_new_note" | "update_existing" | "answer" | "list_items" | "delete_target",
|
||||
"reply": "short reply preview",
|
||||
"target_item_id": "optional item id",
|
||||
"formatted_content": "text to append or create",
|
||||
"create_title": "short AI-generated title when creating a new note",
|
||||
"answer": "short answer when action=answer",
|
||||
"source_item_ids": ["id1", "id2"],
|
||||
"match_confidence": "high" | "low"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Use existing items only when the topical match is strong.
|
||||
- If the match is weak or ambiguous, create a new note.
|
||||
- The user prefers speed: do not ask which note to use.
|
||||
- If the user explicitly says update, change, edit, replace, set, or correct, prefer update_existing when one strong existing note clearly matches.
|
||||
- If the user is asking to list what they have saved, prefer list_items instead of answer.
|
||||
- For short tips/advice, format as bullets when natural.
|
||||
- Only fix spelling and grammar. Do not rewrite the meaning.
|
||||
- For questions, answer briefly and cite up to 3 source ids.
|
||||
- For list_items, return a concise list-style reply and cite up to 12 source ids.
|
||||
- For delete requests, choose the most likely target and set action=delete_target.
|
||||
- Never choose append_existing unless target_item_id is one of the candidate ids.
|
||||
- Never choose update_existing unless target_item_id is one of the candidate ids.
|
||||
- If all candidates are weak, set action=create_new_note and match_confidence=low.
|
||||
- The current assistant state is only for context:
|
||||
${JSON.stringify(state, null, 2)}
|
||||
- listing_intent=${listingIntent}
|
||||
`;
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: env.OPENAI_MODEL || 'gpt-5.2',
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.2,
|
||||
max_completion_tokens: 1100,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages,
|
||||
{
|
||||
role: 'user',
|
||||
content: `Candidate items:\n${buildCandidateSummary(candidates)}`
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
const content = raw?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Assistant response was empty.');
|
||||
}
|
||||
|
||||
return JSON.parse(content) as {
|
||||
action?: 'append_existing' | 'create_new_note' | 'update_existing' | 'answer' | 'list_items' | 'delete_target';
|
||||
reply?: string;
|
||||
target_item_id?: string;
|
||||
formatted_content?: string;
|
||||
create_title?: string;
|
||||
answer?: string;
|
||||
source_item_ids?: string[];
|
||||
match_confidence?: 'high' | 'low';
|
||||
};
|
||||
}
|
||||
|
||||
async function rewriteExistingItemContent(
|
||||
messages: Array<{ role: ChatRole; content: string }>,
|
||||
target: BrainItem,
|
||||
userInstruction: string
|
||||
): Promise<string> {
|
||||
const existing = (target.raw_content || target.extracted_text || '').trim();
|
||||
if (!existing) {
|
||||
throw new Error('Target item has no editable content.');
|
||||
}
|
||||
|
||||
const systemPrompt = `You edit an existing personal note in place.
|
||||
|
||||
Return ONLY JSON:
|
||||
{
|
||||
"updated_content": "full updated note body"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Apply the user's requested update to the existing note.
|
||||
- Preserve the note's structure and wording as much as possible.
|
||||
- Make the smallest correct change needed.
|
||||
- Do not append a new line when the user clearly wants an existing value updated.
|
||||
- Only fix spelling and grammar where needed for the changed text.
|
||||
- Return the full updated note body, not a diff.`;
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.OPENAI_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: env.OPENAI_MODEL || 'gpt-5.2',
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.1,
|
||||
max_completion_tokens: 900,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.slice(-8),
|
||||
{
|
||||
role: 'user',
|
||||
content: `Target title: ${target.title || 'Untitled'}\n\nExisting note:\n${existing}\n\nInstruction: ${userInstruction}`
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
const content = raw?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Update response was empty.');
|
||||
}
|
||||
const parsed = JSON.parse(content) as { updated_content?: string };
|
||||
const updated = parsed.updated_content?.trim();
|
||||
if (!updated) {
|
||||
throw new Error('Updated content was empty.');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
function sourceLinksFromIds(items: BrainItem[], ids: string[] | undefined): SourceLink[] {
|
||||
if (!Array.isArray(ids) || !ids.length) return [];
|
||||
const map = new Map(items.map((item) => [item.id, item]));
|
||||
return ids.map((id) => map.get(id)).filter(Boolean).map((item) => toSource(item as BrainItem));
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, fetch, cookies }) => {
|
||||
if (!cookies.get('platform_session')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
return json({ error: 'Assistant is not configured.' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { messages = [], state = {} } = await request.json().catch(() => ({}));
|
||||
const chat = recentMessages(messages);
|
||||
const userText = lastUserMessage(chat);
|
||||
const currentState: AssistantState = state && typeof state === 'object' ? state : {};
|
||||
|
||||
if (!userText) {
|
||||
return json({
|
||||
reply: 'Tell me what to save, find, answer, or delete.',
|
||||
state: currentState,
|
||||
sources: []
|
||||
});
|
||||
}
|
||||
|
||||
if (currentState.pendingDelete && isConfirmation(userText)) {
|
||||
const target = currentState.pendingDelete;
|
||||
const ok = await deleteItem(fetch, target.itemId);
|
||||
if (!ok) {
|
||||
return json({ reply: `I couldn't delete "${target.itemTitle}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Deleted "${target.itemTitle}".`,
|
||||
state: {},
|
||||
sources: []
|
||||
});
|
||||
}
|
||||
|
||||
if (currentState.lastMutation && isUndo(userText)) {
|
||||
if (currentState.lastMutation.type === 'append' && currentState.lastMutation.additionId) {
|
||||
const ok = await deleteAddition(fetch, currentState.lastMutation.itemId, currentState.lastMutation.additionId);
|
||||
return json({
|
||||
reply: ok ? `Undid the change in "${currentState.lastMutation.itemTitle}".` : 'I could not undo that change yet.',
|
||||
state: ok ? {} : currentState,
|
||||
sources: ok ? [{ id: currentState.lastMutation.itemId, title: currentState.lastMutation.itemTitle, type: 'note', href: `/brain?item=${currentState.lastMutation.itemId}` }] : []
|
||||
});
|
||||
}
|
||||
if (currentState.lastMutation.type === 'create' && currentState.lastMutation.createdItemId) {
|
||||
const ok = await deleteItem(fetch, currentState.lastMutation.createdItemId);
|
||||
return json({
|
||||
reply: ok ? `Removed "${currentState.lastMutation.itemTitle}".` : 'I could not undo that note creation yet.',
|
||||
state: ok ? {} : currentState,
|
||||
sources: []
|
||||
});
|
||||
}
|
||||
if (currentState.lastMutation.type === 'update' && currentState.lastMutation.previousRawContent !== undefined) {
|
||||
const restored = await updateBrainItem(fetch, currentState.lastMutation.itemId, {
|
||||
raw_content: currentState.lastMutation.previousRawContent
|
||||
});
|
||||
return json({
|
||||
reply: restored.ok ? `Undid the update in "${currentState.lastMutation.itemTitle}".` : 'I could not undo that update yet.',
|
||||
state: restored.ok ? {} : currentState,
|
||||
sources: restored.ok ? [{ id: currentState.lastMutation.itemId, title: currentState.lastMutation.itemTitle, type: 'note', href: `/brain?item=${currentState.lastMutation.itemId}` }] : []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentState.lastMutation && wantsNewNoteInstead(userText)) {
|
||||
const content = currentState.lastMutation.content;
|
||||
if (currentState.lastMutation.type === 'append' && currentState.lastMutation.additionId) {
|
||||
await deleteAddition(fetch, currentState.lastMutation.itemId, currentState.lastMutation.additionId);
|
||||
}
|
||||
const titleDecision = await decideAction(
|
||||
[{ role: 'user', content: `Create a concise title for this note:\n${content}` }],
|
||||
[],
|
||||
currentState
|
||||
).catch(() => ({ create_title: 'New Note' }));
|
||||
const created = await createBrainNote(fetch, content, titleDecision.create_title);
|
||||
if (!created.ok) {
|
||||
return json({ reply: 'I could not create the new note yet.', state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
const createdItem = created.body as BrainItem;
|
||||
return json({
|
||||
reply: `Created "${createdItem.title || titleDecision.create_title || 'New note'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'create',
|
||||
itemId: createdItem.id,
|
||||
createdItemId: createdItem.id,
|
||||
itemTitle: createdItem.title || titleDecision.create_title || 'New note',
|
||||
content
|
||||
}
|
||||
},
|
||||
sources: [toSource(createdItem)]
|
||||
});
|
||||
}
|
||||
|
||||
const retarget = currentState.lastMutation ? moveTargetFromText(userText) : null;
|
||||
if (currentState.lastMutation && retarget) {
|
||||
if (currentState.lastMutation.type === 'append' && currentState.lastMutation.additionId) {
|
||||
await deleteAddition(fetch, currentState.lastMutation.itemId, currentState.lastMutation.additionId);
|
||||
} else if (currentState.lastMutation.type === 'create' && currentState.lastMutation.createdItemId) {
|
||||
await deleteItem(fetch, currentState.lastMutation.createdItemId);
|
||||
}
|
||||
|
||||
const candidates = await collectCandidates(fetch, chat, retarget, 8);
|
||||
const target = candidates[0];
|
||||
if (!target) {
|
||||
const created = await createBrainNote(fetch, currentState.lastMutation.content, retarget);
|
||||
if (!created.ok) {
|
||||
return json({ reply: 'I could not move that into a new note yet.', state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
const createdItem = created.body as BrainItem;
|
||||
return json({
|
||||
reply: `Created "${createdItem.title || retarget}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'create',
|
||||
itemId: createdItem.id,
|
||||
createdItemId: createdItem.id,
|
||||
itemTitle: createdItem.title || retarget,
|
||||
content: currentState.lastMutation.content
|
||||
}
|
||||
},
|
||||
sources: [toSource(createdItem)]
|
||||
});
|
||||
}
|
||||
|
||||
const appended = await appendToItem(fetch, target.id, currentState.lastMutation.content);
|
||||
if (!appended.ok) {
|
||||
return json({ reply: `I couldn't move that to "${target.title || 'that item'}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Moved it to "${target.title || 'Untitled'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'append',
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled',
|
||||
additionId: appended.body?.id,
|
||||
content: currentState.lastMutation.content
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
}
|
||||
|
||||
const listingIntent = isListingIntent(userText);
|
||||
const candidates = await collectCandidates(fetch, chat, userText, listingIntent ? 24 : 8);
|
||||
|
||||
if (isExplicitUpdateRequest(userText) && candidates[0] && (candidates[0].raw_content || candidates[0].extracted_text)) {
|
||||
const target = candidates[0];
|
||||
try {
|
||||
const updatedContent = await rewriteExistingItemContent(chat, target, userText);
|
||||
const updated = await updateBrainItem(fetch, target.id, { raw_content: updatedContent });
|
||||
if (!updated.ok) {
|
||||
return json({ reply: `I couldn't update "${target.title || 'Untitled'}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Updated "${target.title || 'Untitled'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'update',
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled',
|
||||
content: updatedContent,
|
||||
previousRawContent: target.raw_content || target.extracted_text || ''
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
reply: `I couldn't update "${target.title || 'Untitled'}" yet.`,
|
||||
state: currentState,
|
||||
sources: [toSource(target)],
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsDelete(userText) && !currentState.pendingDelete) {
|
||||
const target = candidates[0];
|
||||
if (!target) {
|
||||
return json({ reply: "I couldn't find a note to delete.", state: currentState, sources: [] });
|
||||
}
|
||||
return json({
|
||||
reply: `Delete "${target.title || 'Untitled'}"?`,
|
||||
state: {
|
||||
...currentState,
|
||||
pendingDelete: {
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled'
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
}
|
||||
|
||||
let decision;
|
||||
try {
|
||||
decision = await decideAction(chat, candidates, currentState, listingIntent);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
reply: 'The Brain assistant did not respond cleanly.',
|
||||
state: currentState,
|
||||
sources: [],
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (decision.action === 'answer') {
|
||||
return json({
|
||||
reply: decision.answer || decision.reply || 'I found a few relevant notes.',
|
||||
state: currentState,
|
||||
sources: sourceLinksFromIds(candidates, decision.source_item_ids)
|
||||
});
|
||||
}
|
||||
|
||||
if (decision.action === 'list_items') {
|
||||
return json({
|
||||
reply: decision.answer || decision.reply || 'Here are the matching items I found.',
|
||||
state: currentState,
|
||||
sources: sourceLinksFromIds(candidates, decision.source_item_ids)
|
||||
});
|
||||
}
|
||||
|
||||
if (decision.action === 'delete_target' && decision.target_item_id) {
|
||||
const target = candidates.find((item) => item.id === decision.target_item_id) || (await getItem(fetch, decision.target_item_id));
|
||||
if (!target) {
|
||||
return json({ reply: "I couldn't find that note to delete.", state: currentState, sources: [] });
|
||||
}
|
||||
return json({
|
||||
reply: `Delete "${target.title || 'Untitled'}"?`,
|
||||
state: {
|
||||
...currentState,
|
||||
pendingDelete: {
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled'
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
}
|
||||
|
||||
if (decision.action === 'update_existing' && decision.target_item_id && decision.match_confidence === 'high') {
|
||||
const target = candidates.find((item) => item.id === decision.target_item_id) || (await getItem(fetch, decision.target_item_id));
|
||||
if (target && (target.raw_content || target.extracted_text)) {
|
||||
try {
|
||||
const updatedContent = await rewriteExistingItemContent(chat, target, userText);
|
||||
const updated = await updateBrainItem(fetch, target.id, { raw_content: updatedContent });
|
||||
if (!updated.ok) {
|
||||
return json({ reply: `I couldn't update "${target.title || 'Untitled'}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Updated "${target.title || 'Untitled'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'update',
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled',
|
||||
content: updatedContent,
|
||||
previousRawContent: target.raw_content || target.extracted_text || ''
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
reply: `I couldn't update "${target.title || 'Untitled'}" yet.`,
|
||||
state: currentState,
|
||||
sources: [toSource(target)],
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = decision.formatted_content?.trim() || userText;
|
||||
|
||||
if (decision.action === 'append_existing' && decision.target_item_id && decision.match_confidence === 'high') {
|
||||
const target = candidates.find((item) => item.id === decision.target_item_id) || (await getItem(fetch, decision.target_item_id));
|
||||
if (!target) {
|
||||
// fall through to create below
|
||||
} else {
|
||||
const appended = await appendToItem(fetch, target.id, content);
|
||||
if (!appended.ok) {
|
||||
return json({ reply: `I couldn't add that to "${target.title || 'Untitled'}" yet.`, state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
return json({
|
||||
reply: `Added to "${target.title || 'Untitled'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'append',
|
||||
itemId: target.id,
|
||||
itemTitle: target.title || 'Untitled',
|
||||
additionId: appended.body?.id,
|
||||
content
|
||||
}
|
||||
},
|
||||
sources: [toSource(target)]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await createBrainNote(fetch, content, decision.create_title);
|
||||
if (!created.ok) {
|
||||
return json({ reply: 'I could not create the note yet.', state: currentState, sources: [] }, { status: 500 });
|
||||
}
|
||||
const createdItem = created.body as BrainItem;
|
||||
return json({
|
||||
reply: `Created "${createdItem.title || decision.create_title || 'New note'}".`,
|
||||
state: {
|
||||
lastMutation: {
|
||||
type: 'create',
|
||||
itemId: createdItem.id,
|
||||
createdItemId: createdItem.id,
|
||||
itemTitle: createdItem.title || decision.create_title || 'New note',
|
||||
content
|
||||
}
|
||||
},
|
||||
sources: [toSource(createdItem)]
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user