From 7251bf305aa10134c494bb120ded34a1ab79b9b2 Mon Sep 17 00:00:00 2001 From: Zaine Date: Fri, 26 Jun 2026 15:28:47 +0100 Subject: [PATCH] ai with hyper machine --- .env.example | 5 + apps/web/src/app/api/ai/config/route.ts | 27 ++- .../api/ai/memory/summary/rebuild/route.ts | 26 +-- .../components/settings/ai-config-panel.tsx | 113 ++++++++-- .../components/settings/ai-health-panel.tsx | 15 +- apps/web/src/lib/ai/types.ts | 76 +++++++ apps/web/src/lib/config/env.ts | 16 ++ apps/web/src/lib/services/ai-config.ts | 194 +++++++++++++++--- apps/web/src/lib/services/ai-teacher.test.ts | 65 +++--- apps/web/src/lib/services/ai.ts | 68 +++--- .../web/src/lib/services/memory-extraction.ts | 26 +-- 11 files changed, 501 insertions(+), 130 deletions(-) diff --git a/.env.example b/.env.example index 12d5db0..75bf59f 100755 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ OLLAMA_URL=http://localhost:11434 OLLAMA_MODEL_FAST=llama3.2:1b OLLAMA_MODEL_PROSE=llama3.2:1b +# Hyper machine (main PC) — seeds hyper profile defaults in AI settings +# HYPER_OLLAMA_URL=http://192.168.1.50:11434 +# HYPER_OLLAMA_MODEL_FAST=llama3.1:8b +# HYPER_OLLAMA_MODEL_PROSE=llama3.1:8b + # Calibre library (live read-only metadata) CALIBRE_LIBRARY_PATH= CALIBRE_METADATA_DB_PATH= diff --git a/apps/web/src/app/api/ai/config/route.ts b/apps/web/src/app/api/ai/config/route.ts index f43324e..3aabe6f 100755 --- a/apps/web/src/app/api/ai/config/route.ts +++ b/apps/web/src/app/api/ai/config/route.ts @@ -2,17 +2,20 @@ import { handleApi } from "@/lib/api"; import { requireUser } from "@/lib/services/user"; import { getAiBehaviorConfig, - getAiProviderConfig, + getAiProviderSettings, saveAiBehaviorConfig, + saveAiProviderSettings, saveAiProviderConfig, } from "@/lib/services/ai-config"; +import type { AIProviderSettings } from "@/lib/ai/types"; export async function GET() { return handleApi(async () => { const { user } = await requireUser(); const behavior = await getAiBehaviorConfig(user.id); - const provider = await getAiProviderConfig(user.id); - return { behavior, provider }; + const providerSettings = await getAiProviderSettings(user.id); + const provider = providerSettings.profiles[providerSettings.machineMode]; + return { behavior, provider, providerSettings }; }); } @@ -21,13 +24,23 @@ export async function PATCH(request: Request) { return handleApi(async () => { const { user } = await requireUser(); let behavior = await getAiBehaviorConfig(user.id); - let provider = await getAiProviderConfig(user.id); + let providerSettings = await getAiProviderSettings(user.id); + if (body.behavior) { behavior = await saveAiBehaviorConfig(user.id, body.behavior); } - if (body.provider) { - provider = await saveAiProviderConfig(user.id, body.provider); + + if (body.providerSettings) { + providerSettings = await saveAiProviderSettings( + user.id, + body.providerSettings as Partial + ); + } else if (body.provider) { + await saveAiProviderConfig(user.id, body.provider); + providerSettings = await getAiProviderSettings(user.id); } - return { behavior, provider }; + + const provider = providerSettings.profiles[providerSettings.machineMode]; + return { behavior, provider, providerSettings }; }); } diff --git a/apps/web/src/app/api/ai/memory/summary/rebuild/route.ts b/apps/web/src/app/api/ai/memory/summary/rebuild/route.ts index 8f09386..dd19e2b 100644 --- a/apps/web/src/app/api/ai/memory/summary/rebuild/route.ts +++ b/apps/web/src/app/api/ai/memory/summary/rebuild/route.ts @@ -1,8 +1,7 @@ import { handleApi } from "@/lib/api"; import { requireUser } from "@/lib/services/user"; import { listMemories, saveProfileSummary } from "@/lib/services/ai-memory"; -import { getProvider } from "@/lib/ai/provider-registry"; -import { getAiBehaviorConfig, getAiProviderConfig, buildSystemPrompt } from "@/lib/services/ai-config"; +import { getAiBehaviorConfig, getAiProviderConfig, generateTextWithFallback, buildSystemPrompt } from "@/lib/services/ai-config"; import { getTemplateBody } from "@/lib/services/ai-templates"; import { renderTemplate } from "@/lib/ai/prompts/render"; @@ -32,16 +31,19 @@ export async function POST() { user_name: user.displayName, context: bulletList, }); - const provider = getProvider(providerConfig.type); - const res = await provider.generateText(providerConfig, { - model: providerConfig.model, - prompt, - system, - format: "text", - temperature: 0.5, - maxTokens: 400, - timeoutMs: providerConfig.timeoutMs, - }); + const res = await generateTextWithFallback( + user.id, + { + model: providerConfig.model, + prompt, + system, + format: "text", + temperature: 0.5, + maxTokens: 400, + timeoutMs: providerConfig.timeoutMs, + }, + behavior + ); if (res.text.trim()) summaryText = res.text.trim(); } catch { /* use bullet list fallback */ diff --git a/apps/web/src/components/settings/ai-config-panel.tsx b/apps/web/src/components/settings/ai-config-panel.tsx index ab9ba47..6821526 100755 --- a/apps/web/src/components/settings/ai-config-panel.tsx +++ b/apps/web/src/components/settings/ai-config-panel.tsx @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useState, useEffect } from "react"; +import type { AIProviderConfig, AIProviderSettings, AIMachineMode } from "@/lib/ai/types"; const PERSONALITIES = [ { id: "supportive_mentor", label: "Supportive Mentor" }, @@ -11,6 +12,34 @@ const PERSONALITIES = [ { id: "friendly_coach", label: "Friendly Coach" }, ]; +const DEFAULT_LOCAL_PROVIDER: AIProviderConfig = { + type: "ollama", + baseUrl: "http://localhost:11434", + model: "llama3.2:1b", + temperature: 0.7, + maxTokens: 2048, + timeoutMs: 60000, + enabled: true, +}; + +const DEFAULT_HYPER_PROVIDER: AIProviderConfig = { + type: "ollama", + baseUrl: "http://192.168.1.50:11434", + model: "llama3.1:8b", + temperature: 0.7, + maxTokens: 2048, + timeoutMs: 120000, + enabled: true, +}; + +const DEFAULT_PROVIDER_SETTINGS: AIProviderSettings = { + machineMode: "local", + profiles: { + local: DEFAULT_LOCAL_PROVIDER, + hyper: DEFAULT_HYPER_PROVIDER, + }, +}; + export function AiConfigPanel() { const qc = useQueryClient(); const { data } = useQuery({ @@ -29,27 +58,46 @@ export function AiConfigPanel() { enabled: true, strictMode: false, }); - const [provider, setProvider] = useState({ - type: "ollama", - baseUrl: "http://localhost:11434", - model: "llama3.2:3b", - temperature: 0.7, - maxTokens: 2048, - timeoutMs: 60000, - enabled: true, - }); + const [providerSettings, setProviderSettings] = + useState(DEFAULT_PROVIDER_SETTINGS); useEffect(() => { if (data?.behavior) setBehavior(data.behavior); - if (data?.provider) setProvider(data.provider); + if (data?.providerSettings) setProviderSettings(data.providerSettings); + else if (data?.provider) { + setProviderSettings({ + machineMode: "local", + profiles: { + local: data.provider, + hyper: DEFAULT_HYPER_PROVIDER, + }, + }); + } }, [data]); + const activeMode = providerSettings.machineMode; + const activeProfile = providerSettings.profiles[activeMode]; + + const updateActiveProfile = (updates: Partial) => { + setProviderSettings({ + ...providerSettings, + profiles: { + ...providerSettings.profiles, + [activeMode]: { ...activeProfile, ...updates }, + }, + }); + }; + + const setMachineMode = (mode: AIMachineMode) => { + setProviderSettings({ ...providerSettings, machineMode: mode }); + }; + const save = useMutation({ mutationFn: async () => { const res = await fetch("/api/ai/config", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ behavior, provider }), + body: JSON.stringify({ behavior, providerSettings }), }); return res.json(); }, @@ -126,11 +174,32 @@ export function AiConfigPanel() {

Provider Settings

+ + + +

+ {activeMode === "hyper" + ? `Hyper: ${providerSettings.profiles.hyper.model} @ ${providerSettings.profiles.hyper.baseUrl}` + : `Local: ${providerSettings.profiles.local.model} @ ${providerSettings.profiles.local.baseUrl}`} + {activeMode === "hyper" && ( + + Falls back to local model automatically when the hyper machine is unreachable. + + )} +

+ setProvider({ ...provider, baseUrl: e.target.value })} + value={activeProfile.baseUrl} + onChange={(e) => updateActiveProfile({ baseUrl: e.target.value })} /> setProvider({ ...provider, model: e.target.value })} + value={activeProfile.model} + onChange={(e) => updateActiveProfile({ model: e.target.value })} /> setProvider({ ...provider, maxTokens: parseInt(e.target.value) })} + value={activeProfile.maxTokens} + onChange={(e) => updateActiveProfile({ maxTokens: parseInt(e.target.value) })} /> setProvider({ ...provider, timeoutMs: parseInt(e.target.value) })} + value={activeProfile.timeoutMs} + onChange={(e) => updateActiveProfile({ timeoutMs: parseInt(e.target.value) })} />

API keys are configured via environment variables only (OPENAI_API_KEY, LLAMACPP_API_KEY). - Never stored in the database. + Never stored in the database. Hyper defaults can be seeded from HYPER_OLLAMA_* env vars on the server.

diff --git a/apps/web/src/components/settings/ai-health-panel.tsx b/apps/web/src/components/settings/ai-health-panel.tsx index 6915e2b..fc2386f 100755 --- a/apps/web/src/components/settings/ai-health-panel.tsx +++ b/apps/web/src/components/settings/ai-health-panel.tsx @@ -1,7 +1,6 @@ "use client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; export function AiHealthPanel() { const qc = useQueryClient(); @@ -35,9 +34,20 @@ export function AiHealthPanel() {

AI Health

- Check whether your local AI provider is reachable and responding. + Check whether your AI provider is reachable and responding.

+ {health?.fallbackActive && ( +
+ Hyper machine unreachable — using local model. + {health.requestedMode && health.effectiveMode && ( + + Requested: {health.requestedMode} · Effective: {health.effectiveMode} + + )} +
+ )} + {isLoading ? (

Loading...

) : ( @@ -58,6 +68,7 @@ export function AiHealthPanel() {
+ diff --git a/apps/web/src/lib/ai/types.ts b/apps/web/src/lib/ai/types.ts index d7b9987..1198eeb 100755 --- a/apps/web/src/lib/ai/types.ts +++ b/apps/web/src/lib/ai/types.ts @@ -58,6 +58,24 @@ export interface AIProviderConfig { enabled: boolean; } +export type AIMachineMode = "local" | "hyper"; + +export interface AIProviderSettings { + machineMode: AIMachineMode; + profiles: { + local: AIProviderConfig; + hyper: AIProviderConfig; + }; +} + +export interface AIHealthCacheMeta { + fallbackActive?: boolean; + requestedMode?: AIMachineMode; + effectiveMode?: AIMachineMode; +} + +export type AIHealthCache = AIHealthResult & AIHealthCacheMeta; + export interface AIBehaviorConfig { personality: "supportive_mentor" | "wise_teacher" | "quiet_observer" | "academic_tutor" | "friendly_coach"; verbosity: "minimal" | "balanced" | "detailed"; @@ -88,6 +106,64 @@ export const DEFAULT_AI_PROVIDER: AIProviderConfig = { enabled: true, }; +export const DEFAULT_HYPER_AI_PROVIDER: AIProviderConfig = { + type: "ollama", + baseUrl: env.hyperOllamaUrl, + model: env.hyperOllamaModelFast, + temperature: 0.7, + maxTokens: 2048, + timeoutMs: 120000, + enabled: true, +}; + +export const DEFAULT_AI_PROVIDER_SETTINGS: AIProviderSettings = { + machineMode: "local", + profiles: { + local: DEFAULT_AI_PROVIDER, + hyper: DEFAULT_HYPER_AI_PROVIDER, + }, +}; + +function isLegacyProviderConfig(value: unknown): value is AIProviderConfig { + return ( + typeof value === "object" && + value !== null && + "type" in value && + "baseUrl" in value && + !("machineMode" in value) + ); +} + +export function migrateProviderSettings(value: unknown): AIProviderSettings { + if ( + typeof value === "object" && + value !== null && + "machineMode" in value && + "profiles" in value + ) { + const settings = value as AIProviderSettings; + return { + machineMode: settings.machineMode ?? "local", + profiles: { + local: { ...DEFAULT_AI_PROVIDER, ...settings.profiles?.local }, + hyper: { ...DEFAULT_HYPER_AI_PROVIDER, ...settings.profiles?.hyper }, + }, + }; + } + + const local = isLegacyProviderConfig(value) + ? { ...DEFAULT_AI_PROVIDER, ...value } + : DEFAULT_AI_PROVIDER; + + return { + machineMode: "local", + profiles: { + local, + hyper: DEFAULT_HYPER_AI_PROVIDER, + }, + }; +} + export function redactUrl(url: string): string { try { const u = new URL(url); diff --git a/apps/web/src/lib/config/env.ts b/apps/web/src/lib/config/env.ts index 6c20df9..6027431 100755 --- a/apps/web/src/lib/config/env.ts +++ b/apps/web/src/lib/config/env.ts @@ -35,6 +35,22 @@ export const env = { return process.env.OLLAMA_MODEL_PROSE ?? process.env.OLLAMA_MODEL_FAST ?? "llama3.2:3b"; }, + get hyperOllamaUrl(): string { + return process.env.HYPER_OLLAMA_URL ?? "http://192.168.1.50:11434"; + }, + + get hyperOllamaModelFast(): string { + return process.env.HYPER_OLLAMA_MODEL_FAST ?? "llama3.1:8b"; + }, + + get hyperOllamaModelProse(): string { + return ( + process.env.HYPER_OLLAMA_MODEL_PROSE ?? + process.env.HYPER_OLLAMA_MODEL_FAST ?? + "llama3.1:8b" + ); + }, + get openAiApiKey(): string | undefined { return process.env.OPENAI_API_KEY; }, diff --git a/apps/web/src/lib/services/ai-config.ts b/apps/web/src/lib/services/ai-config.ts index 9cca7a4..778905e 100755 --- a/apps/web/src/lib/services/ai-config.ts +++ b/apps/web/src/lib/services/ai-config.ts @@ -2,14 +2,21 @@ import { eq, and, desc } from "drizzle-orm"; import { db, settings, aiHealthLog } from "../db"; import { DEFAULT_AI_BEHAVIOR, - DEFAULT_AI_PROVIDER, + DEFAULT_AI_PROVIDER_SETTINGS, + migrateProviderSettings, type AIBehaviorConfig, type AIProviderConfig, + type AIProviderSettings, type AIHealthResult, + type AIHealthCache, + type AIMachineMode, + type GenerateTextRequest, + type GenerateTextResponse, } from "../ai/types"; import { getProvider } from "../ai/provider-registry"; +import { isAiTimeoutError } from "../ai/ai-normalize"; import { PERSONALITY_MODIFIERS, VERBOSITY_MODIFIERS } from "../ai/prompts/defaults"; -import { SETTINGS_KEYS } from "@/lib/config"; +import { SETTINGS_KEYS, env } from "@/lib/config"; export async function getAiBehaviorConfig(userId: string): Promise { const [row] = await db @@ -20,13 +27,26 @@ export async function getAiBehaviorConfig(userId: string): Promise) }; } -export async function getAiProviderConfig(userId: string): Promise { +export async function getAiProviderSettings(userId: string): Promise { const [row] = await db .select() .from(settings) .where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiProvider))); - if (!row?.value) return DEFAULT_AI_PROVIDER; - return { ...DEFAULT_AI_PROVIDER, ...(row.value as Partial) }; + if (!row?.value) return DEFAULT_AI_PROVIDER_SETTINGS; + return migrateProviderSettings(row.value); +} + +export async function getAiProviderConfig(userId: string): Promise { + const providerSettings = await getAiProviderSettings(userId); + return providerSettings.profiles[providerSettings.machineMode]; +} + +export async function getProseModel(userId: string): Promise { + const providerSettings = await getAiProviderSettings(userId); + if (providerSettings.machineMode === "hyper") { + return env.hyperOllamaModelProse; + } + return env.ollamaModelProse; } export async function saveAiBehaviorConfig(userId: string, config: Partial) { @@ -43,9 +63,18 @@ export async function saveAiBehaviorConfig(userId: string, config: Partial) { - const current = await getAiProviderConfig(userId); - const merged = { ...current, ...config }; +export async function saveAiProviderSettings( + userId: string, + config: Partial +): Promise { + const current = await getAiProviderSettings(userId); + const merged: AIProviderSettings = { + machineMode: config.machineMode ?? current.machineMode, + profiles: { + local: { ...current.profiles.local, ...config.profiles?.local }, + hyper: { ...current.profiles.hyper, ...config.profiles?.hyper }, + }, + }; const { sql } = await import("drizzle-orm"); await db .insert(settings) @@ -57,32 +86,40 @@ export async function saveAiProviderConfig(userId: string, config: Partial { +export async function saveAiProviderConfig(userId: string, config: Partial) { + const current = await getAiProviderSettings(userId); + const mode = current.machineMode; + return saveAiProviderSettings(userId, { + profiles: { + [mode]: { ...current.profiles[mode], ...config }, + }, + }); +} + +export async function getCachedHealth(userId: string): Promise { const [row] = await db .select() .from(settings) .where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiHealthCache))); - return (row?.value as AIHealthResult) ?? null; + return (row?.value as AIHealthCache) ?? null; } -export async function runHealthCheck(userId: string): Promise { - const config = await getAiProviderConfig(userId); - const provider = getProvider(config.type); - const result = await provider.healthCheck(config); +export function isConnectionError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (isAiTimeoutError(error)) return true; + const msg = error.message.toLowerCase(); + return ( + msg.includes("fetch failed") || + msg.includes("connection failed") || + msg.includes("econnrefused") || + msg.includes("enotfound") || + msg.includes("network") || + msg.includes("unable to connect") + ); +} +async function cacheHealthResult(userId: string, result: AIHealthCache) { const { sql } = await import("drizzle-orm"); - await db.insert(aiHealthLog).values({ - userId, - providerType: config.type, - status: result.status, - latencyMs: result.latencyMs, - model: config.model, - contextLength: result.contextLength, - memoryUsageMb: result.memoryUsageMb, - baseUrlSafe: result.baseUrlSafe, - errorMessage: result.lastError, - }); - await db .insert(settings) .values({ userId, key: SETTINGS_KEYS.aiHealthCache, value: result }) @@ -90,8 +127,111 @@ export async function runHealthCheck(userId: string): Promise { target: [settings.userId, settings.key], set: { value: sql`excluded.value` }, }); +} - return result; +export async function generateTextWithFallback( + userId: string, + req: GenerateTextRequest, + behavior: AIBehaviorConfig +): Promise { + const providerSettings = await getAiProviderSettings(userId); + const requestedMode = providerSettings.machineMode; + const config = providerSettings.profiles[requestedMode]; + const provider = getProvider(config.type); + + try { + const res = await provider.generateText(config, req); + const cached = await getCachedHealth(userId); + if (cached?.fallbackActive) { + await cacheHealthResult(userId, { + ...cached, + fallbackActive: false, + requestedMode, + effectiveMode: requestedMode, + }); + } + return { ...res, fallbackActive: false, effectiveMode: requestedMode }; + } catch (error) { + if ( + requestedMode === "hyper" && + !behavior.strictMode && + isConnectionError(error) + ) { + const localConfig = providerSettings.profiles.local; + const localProvider = getProvider(localConfig.type); + const res = await localProvider.generateText(localConfig, req); + const cached = await getCachedHealth(userId); + await cacheHealthResult(userId, { + ...(cached ?? { + status: "online", + provider: localConfig.type, + model: localConfig.model, + latencyMs: res.latencyMs, + lastSuccessAt: new Date().toISOString(), + lastFailureAt: null, + lastError: null, + baseUrlSafe: null, + contextLength: null, + memoryUsageMb: null, + modelsAvailable: [], + }), + fallbackActive: true, + requestedMode: "hyper", + effectiveMode: "local", + lastError: + error instanceof Error + ? `Hyper machine unreachable: ${error.message}` + : "Hyper machine unreachable", + }); + return { ...res, fallbackActive: true, effectiveMode: "local" }; + } + throw error; + } +} + +export async function runHealthCheck(userId: string): Promise { + const providerSettings = await getAiProviderSettings(userId); + const requestedMode = providerSettings.machineMode; + const config = providerSettings.profiles[requestedMode]; + const provider = getProvider(config.type); + const result = await provider.healthCheck(config); + + let cacheResult: AIHealthCache = { + ...result, + fallbackActive: false, + requestedMode, + effectiveMode: requestedMode, + }; + + if (requestedMode === "hyper" && result.status === "offline") { + const localConfig = providerSettings.profiles.local; + const localProvider = getProvider(localConfig.type); + const localResult = await localProvider.healthCheck(localConfig); + if (localResult.status !== "offline") { + cacheResult = { + ...localResult, + fallbackActive: true, + requestedMode: "hyper", + effectiveMode: "local", + lastError: result.lastError ?? "Hyper machine unreachable", + }; + } + } + + await db.insert(aiHealthLog).values({ + userId, + providerType: cacheResult.provider, + status: cacheResult.status, + latencyMs: cacheResult.latencyMs, + model: cacheResult.model, + contextLength: cacheResult.contextLength, + memoryUsageMb: cacheResult.memoryUsageMb, + baseUrlSafe: cacheResult.baseUrlSafe, + errorMessage: cacheResult.lastError, + }); + + await cacheHealthResult(userId, cacheResult); + return cacheResult; } export async function getLastHealthLogs(userId: string, limit = 10) { diff --git a/apps/web/src/lib/services/ai-teacher.test.ts b/apps/web/src/lib/services/ai-teacher.test.ts index 29912ba..f5a2bbd 100755 --- a/apps/web/src/lib/services/ai-teacher.test.ts +++ b/apps/web/src/lib/services/ai-teacher.test.ts @@ -4,6 +4,9 @@ import { teacherSchema } from "./ai"; vi.mock("./ai-config", () => ({ getAiBehaviorConfig: vi.fn(), getAiProviderConfig: vi.fn(), + getAiProviderSettings: vi.fn(), + getProseModel: vi.fn(async () => "test-prose"), + generateTextWithFallback: vi.fn(), buildSystemPrompt: vi.fn(() => "system"), })); @@ -19,7 +22,7 @@ vi.mock("./user", () => ({ requireUser: vi.fn(async () => ({ user: { id: "user-1", displayName: "Traveler" } })), })); -import { getAiBehaviorConfig, getAiProviderConfig } from "./ai-config"; +import { getAiBehaviorConfig, getAiProviderConfig, getAiProviderSettings, generateTextWithFallback } from "./ai-config"; import { getProvider } from "../ai/provider-registry"; const onlineAvailability = { @@ -40,6 +43,14 @@ const onlineProviderConfig = { timeoutMs: 30000, }; +const providerSettings = { + machineMode: "local" as const, + profiles: { + local: onlineProviderConfig, + hyper: onlineProviderConfig, + }, +}; + describe("teacherSchema", () => { it("coerces string quiz answers to numbers", () => { const parsed = teacherSchema.parse({ @@ -97,27 +108,31 @@ describe("generateTeacherContent", () => { it("returns AI content when provider succeeds", async () => { vi.mocked(getAiBehaviorConfig).mockResolvedValue(onlineAvailability); vi.mocked(getAiProviderConfig).mockResolvedValue(onlineProviderConfig); + vi.mocked(getAiProviderSettings).mockResolvedValue(providerSettings); + vi.mocked(generateTextWithFallback).mockResolvedValue({ + text: JSON.stringify({ + overview: "Intro", + explanation: "Details here", + flashcards: [{ front: "What is Rust?", back: "A systems language" }], + quiz: [ + { + question: "Rust is?", + options: ["Fast", "Slow"], + answer: 0, + }, + ], + researchAssignment: { steps: ["Read chapter 1"], expectedOutcome: "Notes" }, + }), + model: "test", + latencyMs: 10, + fallbackActive: false, + effectiveMode: "local", + }); vi.mocked(getProvider).mockReturnValue({ type: "ollama", healthCheck: vi.fn(async () => ({ status: "online" as const })), listModels: vi.fn(async () => []), - generateText: vi.fn(async () => ({ - text: JSON.stringify({ - overview: "Intro", - explanation: "Details here", - flashcards: [{ front: "What is Rust?", back: "A systems language" }], - quiz: [ - { - question: "Rust is?", - options: ["Fast", "Slow"], - answer: 0, - }, - ], - researchAssignment: { steps: ["Read chapter 1"], expectedOutcome: "Notes" }, - }), - model: "test", - latencyMs: 10, - })), + generateText: vi.fn(), }); const { generateTeacherContent } = await import("./ai"); @@ -145,15 +160,19 @@ describe("generateTeacherContent", () => { it("returns fallback when AI JSON is invalid and strictMode is off", async () => { vi.mocked(getAiBehaviorConfig).mockResolvedValue(onlineAvailability); vi.mocked(getAiProviderConfig).mockResolvedValue(onlineProviderConfig); + vi.mocked(getAiProviderSettings).mockResolvedValue(providerSettings); + vi.mocked(generateTextWithFallback).mockResolvedValue({ + text: "not valid json", + model: "test", + latencyMs: 10, + fallbackActive: false, + effectiveMode: "local", + }); vi.mocked(getProvider).mockReturnValue({ type: "ollama", healthCheck: vi.fn(async () => ({ status: "online" as const })), listModels: vi.fn(async () => []), - generateText: vi.fn(async () => ({ - text: "not valid json", - model: "test", - latencyMs: 10, - })), + generateText: vi.fn(), }); const { generateTeacherContent } = await import("./ai"); diff --git a/apps/web/src/lib/services/ai.ts b/apps/web/src/lib/services/ai.ts index 64695ae..f680587 100755 --- a/apps/web/src/lib/services/ai.ts +++ b/apps/web/src/lib/services/ai.ts @@ -17,12 +17,14 @@ import { parseAiJson } from "../ai/parse-json"; import { getAiBehaviorConfig, getAiProviderConfig, + getAiProviderSettings, + getProseModel, + generateTextWithFallback, buildSystemPrompt, } from "./ai-config"; import { getTemplateBody } from "./ai-templates"; import { renderTemplate } from "../ai/prompts/render"; import { requireUser } from "./user"; -import { env } from "@/lib/config"; const questSchema = z.object({ quests: z @@ -143,13 +145,23 @@ export async function getAiAvailability(userId: string) { const behavior = await getAiBehaviorConfig(userId); if (!behavior.enabled) return { canUse: false, online: false }; - const providerConfig = await getAiProviderConfig(userId); + const providerSettings = await getAiProviderSettings(userId); + const providerConfig = providerSettings.profiles[providerSettings.machineMode]; if (!providerConfig.enabled) return { canUse: false, online: false }; try { const provider = getProvider(providerConfig.type); const health = await provider.healthCheck(providerConfig); - return { canUse: true, online: health.status === "online" }; + if (health.status === "online") { + return { canUse: true, online: true }; + } + if (providerSettings.machineMode === "hyper") { + const localConfig = providerSettings.profiles.local; + const localProvider = getProvider(localConfig.type); + const localHealth = await localProvider.healthCheck(localConfig); + return { canUse: true, online: localHealth.status === "online" }; + } + return { canUse: true, online: false }; } catch { return { canUse: true, online: false }; } @@ -183,22 +195,25 @@ async function aiGenerate( }); try { - const provider = getProvider(providerConfig.type); const model = modelOverride ?? (templateKey === "weekly_review" - ? env.ollamaModelProse + ? await getProseModel(userId) : providerConfig.model); - const res = await provider.generateText(providerConfig, { - model, - prompt, - system, - temperature: behavior.creativity, - maxTokens: providerConfig.maxTokens, - format, - timeoutMs: providerConfig.timeoutMs, - }); + const res = await generateTextWithFallback( + userId, + { + model, + prompt, + system, + temperature: behavior.creativity, + maxTokens: providerConfig.maxTokens, + format, + timeoutMs: providerConfig.timeoutMs, + }, + behavior + ); return res.text; } catch (e) { if (behavior.strictMode) throw e; @@ -381,7 +396,7 @@ export async function generateMentorReview(context: Record, use "system_core", "system_weekly_review", { context: JSON.stringify(context) }, - env.ollamaModelProse + await getProseModel(uid) ); if (raw) { try { @@ -514,16 +529,19 @@ export async function generateChatReply( .filter(Boolean) .join("\n"); - const provider = getProvider(providerConfig.type); - const res = await provider.generateText(providerConfig, { - model: providerConfig.model, - prompt, - system, - format: "text", - temperature: behavior.creativity, - maxTokens: Math.min(providerConfig.maxTokens, 800), - timeoutMs: providerConfig.timeoutMs, - }); + const res = await generateTextWithFallback( + userId, + { + model: providerConfig.model, + prompt, + system, + format: "text", + temperature: behavior.creativity, + maxTokens: Math.min(providerConfig.maxTokens, 800), + timeoutMs: providerConfig.timeoutMs, + }, + behavior + ); return res.text.trim() || null; } catch (e) { if (behavior.strictMode) throw e; diff --git a/apps/web/src/lib/services/memory-extraction.ts b/apps/web/src/lib/services/memory-extraction.ts index 47a715d..f902188 100644 --- a/apps/web/src/lib/services/memory-extraction.ts +++ b/apps/web/src/lib/services/memory-extraction.ts @@ -2,8 +2,7 @@ import { z } from "zod"; import type { MemoryCategory, MemorySourceType } from "@adventureos/shared"; import { SENSITIVE_MEMORY_CATEGORIES } from "@adventureos/shared"; import { parseAiJson } from "@/lib/ai/parse-json"; -import { getProvider } from "@/lib/ai/provider-registry"; -import { getAiBehaviorConfig, getAiProviderConfig, buildSystemPrompt } from "./ai-config"; +import { getAiBehaviorConfig, getAiProviderConfig, generateTextWithFallback, buildSystemPrompt } from "./ai-config"; import { createSuggestion, getMemoryLearningSettings } from "./ai-memory"; const candidateSchema = z.object({ @@ -43,16 +42,19 @@ If nothing clear, return { "candidates": [] }.`; try { const coreSystem = await getTemplateBody(userId); - const provider = getProvider(providerConfig.type); - const res = await provider.generateText(providerConfig, { - model: providerConfig.model, - prompt, - system: buildSystemPrompt(behavior, coreSystem), - format: "json", - temperature: 0.3, - maxTokens: 400, - timeoutMs: providerConfig.timeoutMs, - }); + const res = await generateTextWithFallback( + userId, + { + model: providerConfig.model, + prompt, + system: buildSystemPrompt(behavior, coreSystem), + format: "json", + temperature: 0.3, + maxTokens: 400, + timeoutMs: providerConfig.timeoutMs, + }, + behavior + ); const parsed = candidateSchema.safeParse(parseAiJson(res.text)); if (!parsed.success) return [];