ai with hyper machine
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-26 15:28:47 +01:00
parent 2c4c9b0595
commit 7251bf305a
11 changed files with 501 additions and 130 deletions

View File

@@ -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=

View File

@@ -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<AIProviderSettings>
);
} 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 };
});
}

View File

@@ -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 */

View File

@@ -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<AIProviderSettings>(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<AIProviderConfig>) => {
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() {
<section className="space-y-2">
<h3 className="text-sm font-bold">Provider Settings</h3>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={activeMode === "hyper"}
onChange={(e) => setMachineMode(e.target.checked ? "hyper" : "local")}
/>
Hyper-machine mode
</label>
<p className="text-xs text-[var(--color-text-muted)]">
{activeMode === "hyper"
? `Hyper: ${providerSettings.profiles.hyper.model} @ ${providerSettings.profiles.hyper.baseUrl}`
: `Local: ${providerSettings.profiles.local.model} @ ${providerSettings.profiles.local.baseUrl}`}
{activeMode === "hyper" && (
<span className="block mt-1">
Falls back to local model automatically when the hyper machine is unreachable.
</span>
)}
</p>
<Field label="Provider Type">
<select
className="retro-window-inset w-full p-2"
value={provider.type}
onChange={(e) => setProvider({ ...provider, type: e.target.value })}
value={activeProfile.type}
onChange={(e) => updateActiveProfile({ type: e.target.value as AIProviderConfig["type"] })}
>
<option value="ollama">Ollama</option>
<option value="openai_compatible">OpenAI Compatible</option>
@@ -140,36 +209,36 @@ export function AiConfigPanel() {
<Field label="Base URL">
<input
className="retro-window-inset w-full p-2"
value={provider.baseUrl}
onChange={(e) => setProvider({ ...provider, baseUrl: e.target.value })}
value={activeProfile.baseUrl}
onChange={(e) => updateActiveProfile({ baseUrl: e.target.value })}
/>
</Field>
<Field label="Model">
<input
className="retro-window-inset w-full p-2"
value={provider.model}
onChange={(e) => setProvider({ ...provider, model: e.target.value })}
value={activeProfile.model}
onChange={(e) => updateActiveProfile({ model: e.target.value })}
/>
</Field>
<Field label="Max Tokens">
<input
type="number"
className="retro-window-inset w-full p-2"
value={provider.maxTokens}
onChange={(e) => setProvider({ ...provider, maxTokens: parseInt(e.target.value) })}
value={activeProfile.maxTokens}
onChange={(e) => updateActiveProfile({ maxTokens: parseInt(e.target.value) })}
/>
</Field>
<Field label="Timeout (ms)">
<input
type="number"
className="retro-window-inset w-full p-2"
value={provider.timeoutMs}
onChange={(e) => setProvider({ ...provider, timeoutMs: parseInt(e.target.value) })}
value={activeProfile.timeoutMs}
onChange={(e) => updateActiveProfile({ timeoutMs: parseInt(e.target.value) })}
/>
</Field>
<p className="text-xs text-[var(--color-text-muted)]">
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.
</p>
</section>

View File

@@ -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() {
<div className="space-y-4">
<h2 className="font-bold">AI Health</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Check whether your local AI provider is reachable and responding.
Check whether your AI provider is reachable and responding.
</p>
{health?.fallbackActive && (
<div className="text-sm text-[var(--color-gold)] retro-window-inset p-3">
Hyper machine unreachable using local model.
{health.requestedMode && health.effectiveMode && (
<span className="block text-xs mt-1 text-[var(--color-text-muted)]">
Requested: {health.requestedMode} · Effective: {health.effectiveMode}
</span>
)}
</div>
)}
{isLoading ? (
<p className="text-sm">Loading...</p>
) : (
@@ -58,6 +68,7 @@ export function AiHealthPanel() {
<div className="grid grid-cols-2 gap-2 text-sm">
<Stat label="Provider" value={health?.provider ?? "—"} />
<Stat label="Model" value={health?.model ?? "—"} />
<Stat label="Mode" value={health?.effectiveMode ?? health?.requestedMode ?? "—"} />
<Stat label="Latency" value={health?.latencyMs != null ? `${health.latencyMs}ms` : "—"} />
<Stat label="Base URL" value={health?.baseUrlSafe ?? "—"} />
<Stat label="Context Length" value={health?.contextLength?.toString() ?? "—"} />

View File

@@ -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);

View File

@@ -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;
},

View File

@@ -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<AIBehaviorConfig> {
const [row] = await db
@@ -20,13 +27,26 @@ export async function getAiBehaviorConfig(userId: string): Promise<AIBehaviorCon
return { ...DEFAULT_AI_BEHAVIOR, ...(row.value as Partial<AIBehaviorConfig>) };
}
export async function getAiProviderConfig(userId: string): Promise<AIProviderConfig> {
export async function getAiProviderSettings(userId: string): Promise<AIProviderSettings> {
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<AIProviderConfig>) };
if (!row?.value) return DEFAULT_AI_PROVIDER_SETTINGS;
return migrateProviderSettings(row.value);
}
export async function getAiProviderConfig(userId: string): Promise<AIProviderConfig> {
const providerSettings = await getAiProviderSettings(userId);
return providerSettings.profiles[providerSettings.machineMode];
}
export async function getProseModel(userId: string): Promise<string> {
const providerSettings = await getAiProviderSettings(userId);
if (providerSettings.machineMode === "hyper") {
return env.hyperOllamaModelProse;
}
return env.ollamaModelProse;
}
export async function saveAiBehaviorConfig(userId: string, config: Partial<AIBehaviorConfig>) {
@@ -43,9 +63,18 @@ export async function saveAiBehaviorConfig(userId: string, config: Partial<AIBeh
return merged;
}
export async function saveAiProviderConfig(userId: string, config: Partial<AIProviderConfig>) {
const current = await getAiProviderConfig(userId);
const merged = { ...current, ...config };
export async function saveAiProviderSettings(
userId: string,
config: Partial<AIProviderSettings>
): Promise<AIProviderSettings> {
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<AIPro
return merged;
}
export async function getCachedHealth(userId: string): Promise<AIHealthResult | null> {
export async function saveAiProviderConfig(userId: string, config: Partial<AIProviderConfig>) {
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<AIHealthCache | null> {
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<AIHealthResult> {
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<AIHealthResult> {
target: [settings.userId, settings.key],
set: { value: sql`excluded.value` },
});
}
return result;
export async function generateTextWithFallback(
userId: string,
req: GenerateTextRequest,
behavior: AIBehaviorConfig
): Promise<GenerateTextResponse & { fallbackActive: boolean; effectiveMode: AIMachineMode }> {
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<AIHealthCache> {
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) {

View File

@@ -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");

View File

@@ -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<string, unknown>, 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;

View File

@@ -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 [];