This commit is contained in:
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() ?? "—"} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user