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 || {}
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user