From 7186221859fe51adea2b0d9479384ffa318d6edc Mon Sep 17 00:00:00 2001 From: danielgarciaperal2001-glitch Date: Tue, 20 Oct 2026 23:56:17 +0200 Subject: [PATCH] feat: implement ai asistant in local ollama --- README.md | 32 ++++- docker-compose.yml | 10 ++ server/src/ai/anthropic.ts | 91 +++++++++++++ server/src/ai/index.ts | 50 +++++++ server/src/ai/ollama.ts | 125 +++++++++++++++++ server/src/ai/types.ts | 47 +++++++ server/src/index.ts | 5 +- server/src/routes/ai.test.ts | 200 ++++++++++++++++++++++++++++ server/src/routes/ai.ts | 117 ++++++++++++---- web/components/TopBar.tsx | 7 +- web/components/widgets/AiWidget.tsx | 109 ++++++++++++++- web/store/terminal.ts | 11 ++ 12 files changed, 768 insertions(+), 36 deletions(-) create mode 100644 server/src/ai/anthropic.ts create mode 100644 server/src/ai/index.ts create mode 100644 server/src/ai/ollama.ts create mode 100644 server/src/ai/types.ts create mode 100644 server/src/routes/ai.test.ts diff --git a/README.md b/README.md index 8fdee53f..2699f512 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ No signup. No credit card. No rate‑limited demo tier. Clone it, `npm install`, - 🏦 **Macro dashboard** — live US Treasury yield curve, VIX, and major index/commodity proxies - 💼 **Portfolio tracker** — log buy/sell transactions, track average cost, realized & unrealized P&L (persisted in SQLite) - 📅 **Calendar** — economic events (Fed, ECB, CPI, NFP and more) with consensus forecast, previous reading and, for the major US/EU releases, the actual outcome; plus a per‑watchlist earnings calendar with click‑through history showing forecast vs. actual EPS for the last several quarters and the stock's next‑day price move -- 🤖 **AI assistant** (optional) — ask questions about the symbol you're looking at, powered by Claude, fully context‑aware of the terminal's current data +- 🤖 **AI assistant** (optional) — ask questions about the symbol you're looking at, powered by Claude, fully context‑aware of the terminal's current data — or run it fully local on an Ollama model, with a picker to switch backend and model on the fly - ⚡ **Near real‑time updates** — quotes and indexes refresh every second with a subtle flash on change, so you always know what just moved - ⌨️ **Keyboard shortcuts** everywhere — `⌘K` to search, `⌥1`–`⌥9` to add any widget @@ -140,6 +140,26 @@ Two optional knobs for the assistant, both standard for the Anthropic SDK: - `ANTHROPIC_BASE_URL` — point the assistant at an Anthropic-compatible endpoint (a self-hosted gateway, an EU-hosted relay, etc.) instead of api.anthropic.com. - `ANTHROPIC_MODEL` — override the model sent in requests (default: `claude-opus-4-8`). Required when the endpoint behind `ANTHROPIC_BASE_URL` serves models under different ids. +### Optional: local models with Ollama + +The assistant can also talk to a **local Ollama daemon** instead of Claude — no key, no data leaving your machine. The AI widget has a picker for both the backend and the model; Claude stays the default until you switch. + +```bash +# 1. install Ollama and pull a model (https://ollama.com) +ollama pull qwen3:8b + +# 2. point the API at the daemon (the default is http://127.0.0.1:11434) +export OLLAMA_BASE_URL=http://127.0.0.1:11434 +npm run dev +``` + +Then open the AI widget and pick **Ollama (local)** and the model to use. The model list is read live from the daemon, so only the models you have pulled show up. + +- `OLLAMA_BASE_URL` — where the Ollama daemon listens (default: `http://127.0.0.1:11434` with `npm run dev`; in Docker it already defaults to `http://host.docker.internal:11434`, i.e. the machine running Docker). To use a daemon on another machine, set `OLLAMA_BASE_URL=http://192.168.1.50:11434` — with Docker Compose, put that line in a `.env` file next to `docker-compose.yml` and `docker compose` picks it up automatically. +- `OLLAMA_MODEL` — preselect a model in the picker (default: the first model the daemon reports). + +The backend is chosen per request by the widget, so you can switch between Claude and a local model without restarting anything. The browser never talks to Ollama directly — the API server does, and it never accepts a client-supplied URL. + ### Security defaults - The API binds to `127.0.0.1` and only accepts browser requests from `http://localhost:3000` by default — nothing else on your network can reach it out of the box. @@ -157,6 +177,13 @@ docker compose up --build Portfolio data persists in the `terminal-data` volume (SQLite, WAL mode). Ports are published on `127.0.0.1` only by default; see [Security defaults](#security-defaults) to expose it deliberately. +The `api` container reaches an Ollama running on your machine through `http://host.docker.internal:11434`, which is already its default, so a local model works out of the box. To point it at a daemon somewhere else, drop a `.env` file next to `docker-compose.yml` — `docker compose` reads it automatically: + +```bash +# .env +OLLAMA_BASE_URL=http://192.168.1.50:11434 +``` +
## 🧱 Tech stack @@ -167,7 +194,7 @@ Portfolio data persists in the `terminal-data` volume (SQLite, WAL mode). Ports | Charts | `lightweight-charts` (candles/indicators) · D3 (heatmap treemap) · Recharts (yield curve) | | Backend | Node.js · Express · TypeScript | | Database | SQLite (`better-sqlite3`, WAL mode) | -| AI | Anthropic Claude (optional) | +| AI | Anthropic Claude (default) · Ollama local models (optional) |
@@ -178,6 +205,7 @@ Portfolio data persists in the `terminal-data` volume (SQLite, WAL mode). Ports │ └── src/ │ ├── providers/ # nasdaq, tradingview, yahoo, stooq, fred, econcalendar, coingecko, binance, news │ ├── routes/ # market, portfolio, ai +│ ├── ai/ # assistant backends: anthropic (Claude), ollama (local) │ ├── cache.ts # TTL cache with stale-while-revalidate fallback │ └── db.ts # SQLite (better-sqlite3, WAL) └── web/ # Next.js 15 + React 19 + Tailwind 4 diff --git a/docker-compose.yml b/docker-compose.yml index c18cb98a..8ede2959 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,12 @@ services: - "127.0.0.1:4000:4000" environment: - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + # Local Ollama (optional). Defaults to the Docker host, so an Ollama + # running on this machine is reachable from inside the container. + # Point it somewhere else by setting OLLAMA_BASE_URL in a .env file + # next to this compose file (docker compose reads it automatically), + # e.g. OLLAMA_BASE_URL=http://192.168.1.50:11434 + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} - API_KEY=${API_KEY:-} - API_HOST=0.0.0.0 # safe: still only published to 127.0.0.1 above - WEB_ORIGIN=${WEB_ORIGIN:-http://localhost:3000} @@ -18,6 +24,10 @@ services: # the web service (see README > Security defaults) — otherwise a caller # could forge X-Forwarded-For to dodge the /api/ai rate limit. - TRUST_PROXY=${TRUST_PROXY:-} + extra_hosts: + # Makes "host.docker.internal" resolve on Linux too (Docker Desktop + # resolves it natively). + - "host.docker.internal:host-gateway" volumes: - terminal-data:/app/data restart: unless-stopped diff --git a/server/src/ai/anthropic.ts b/server/src/ai/anthropic.ts new file mode 100644 index 00000000..ede74059 --- /dev/null +++ b/server/src/ai/anthropic.ts @@ -0,0 +1,91 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { AiUnavailableError, type AiProvider, type ChatRequest } from "./types.js"; + +const DEFAULT_MODEL = "claude-opus-4-8"; + +// Uses ANTHROPIC_API_KEY (or an `ant auth login` profile) from the environment. +let client: Anthropic | null = null; +function getClient(): Anthropic { + if (!client) client = new Anthropic(); + return client; +} + +// The SDK's model listing is reached through a structural type on purpose: +// this module must keep working if the SDK reshapes its client surface. +type ModelsApi = { + models?: { list?: () => Promise<{ data?: Array<{ id?: string }> }> }; +}; + +function credentialsConfigured(): boolean { + return Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN); +} + +let modelCache: { at: number; models: string[] } | null = null; +const MODEL_CACHE_MS = 5 * 60_000; + +export const anthropicProvider: AiProvider = { + id: "anthropic", + label: "Claude (Anthropic)", + get defaultModel(): string { + return process.env.ANTHROPIC_MODEL || DEFAULT_MODEL; + }, + + async listModels(): Promise { + const fallback = [this.defaultModel]; + // Without credentials the SDK can't list anything — don't even try. + if (!credentialsConfigured()) return fallback; + const now = Date.now(); + if (modelCache && now - modelCache.at < MODEL_CACHE_MS) return modelCache.models; + try { + const api = (getClient() as unknown as ModelsApi).models; + const page = await api?.list?.(); + const ids = (page?.data ?? []).map((m) => m.id).filter((id): id is string => Boolean(id)); + const models = ids.length ? ids : fallback; + modelCache = { at: now, models }; + return models; + } catch { + // A gateway that doesn't implement /v1/models is fine: the default model + // is still usable, so never let the listing break the picker. + return modelCache?.models ?? fallback; + } + }, + + async unavailableReason(): Promise { + if (credentialsConfigured()) return null; + // No key in the environment. The SDK may still resolve credentials some + // other way (an `ant auth login` profile, a keyless gateway), so this is + // reported as "not configured" rather than "broken" — the chat request + // itself is what ultimately decides. + return "no ANTHROPIC_API_KEY on the server — set one to use Claude"; + }, + + async chat(req: ChatRequest): Promise { + try { + const response = await getClient().messages.create({ + model: req.model, + max_tokens: 16000, + thinking: { type: "adaptive" }, + system: req.system, + messages: req.messages, + }); + if (response.stop_reason === "refusal") { + return "The assistant declined to answer this request."; + } + return response.content + .filter((b) => b.type === "text") + .map((b) => ("text" in b ? b.text : "")) + .join(""); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/api[_ -]?key|authentication|unauthorized|401/i.test(msg)) { + throw new AiUnavailableError("AI assistant unavailable: set ANTHROPIC_API_KEY on the server."); + } + throw err; + } + }, +}; + +/** Exposed for tests only; not part of the provider contract. */ +export function resetAnthropicCache(): void { + modelCache = null; +} diff --git a/server/src/ai/index.ts b/server/src/ai/index.ts new file mode 100644 index 00000000..8b32644e --- /dev/null +++ b/server/src/ai/index.ts @@ -0,0 +1,50 @@ +import { anthropicProvider } from "./anthropic.js"; +import { ollamaProvider } from "./ollama.js"; +import type { AiProvider, AiProviderId, ProviderInfo } from "./types.js"; + +const PROVIDERS: Record = { + anthropic: anthropicProvider, + ollama: ollamaProvider, +}; + +/** Claude stays the default backend; Ollama is opt-in from the UI. */ +export const DEFAULT_PROVIDER_ID: AiProviderId = "anthropic"; + +export function isProviderId(value: unknown): value is AiProviderId { + return value === "anthropic" || value === "ollama"; +} + +/** + * The backend a request should use: the client's pick, or Claude when the + * client didn't name one. null means "the client named something unknown". + */ +export function pickProvider(value: unknown): AiProviderId | null { + if (value === undefined || value === null || value === "") return DEFAULT_PROVIDER_ID; + return isProviderId(value) ? value : null; +} + +export function getProvider(id: AiProviderId): AiProvider { + return PROVIDERS[id]; +} + +/** Backend catalogue for the picker: what exists, and what works right now. */ +export async function describeProviders(): Promise { + return Promise.all( + Object.values(PROVIDERS).map(async (provider) => { + let detail: string | undefined; + try { + const reason = await provider.unavailableReason(); + if (reason) detail = reason; + } catch (err) { + detail = err instanceof Error ? err.message : String(err); + } + return { + id: provider.id, + label: provider.label, + defaultModel: provider.defaultModel, + available: detail === undefined, + ...(detail ? { detail } : {}), + }; + }) + ); +} diff --git a/server/src/ai/ollama.ts b/server/src/ai/ollama.ts new file mode 100644 index 00000000..56dcf467 --- /dev/null +++ b/server/src/ai/ollama.ts @@ -0,0 +1,125 @@ +import { AiUnavailableError, type AiProvider, type ChatRequest } from "./types.js"; + +const DEFAULT_BASE_URL = "http://127.0.0.1:11434"; +/** The tag list is a local round-trip: a short timeout is enough. */ +const PROBE_TIMEOUT_MS = 2_000; +/** Local models can take a while to load into memory — be generous. */ +const CHAT_TIMEOUT_MS = 300_000; +const TAGS_CACHE_MS = 15_000; + +/** Where the Ollama daemon lives. Never taken from the client: the browser + * must not be able to make the server fetch an arbitrary URL. */ +export function ollamaBaseUrl(): string { + return (process.env.OLLAMA_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ""); +} + +type TagsModel = { name?: string; model?: string; capabilities?: string[] }; +type TagsResponse = { models?: TagsModel[] }; + +// Ollama reports "completion" for audio and embedding models too, so those +// would show up as chat candidates and answer with noise. Keep the ones that +// can actually hold a conversation. +const NON_CHAT_MODEL = /whisper|tts|embed|clip|bert|minilm|mms[-:]|parler|melspectro/i; + +let tagsCache: { at: number; models: string[] } | null = null; + +async function fetchWithTimeout(url: string, init: RequestInit | undefined, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +/** Model ids of the chat-capable models the local daemon has installed. */ +async function fetchTags(): Promise { + const now = Date.now(); + if (tagsCache && now - tagsCache.at < TAGS_CACHE_MS) return tagsCache.models; + + let res: Response; + try { + res = await fetchWithTimeout(`${ollamaBaseUrl()}/api/tags`, undefined, PROBE_TIMEOUT_MS); + } catch { + throw new AiUnavailableError( + `Ollama is not reachable at ${ollamaBaseUrl()} — start it with \`ollama serve\`.` + ); + } + if (!res.ok) { + throw new AiUnavailableError(`Ollama at ${ollamaBaseUrl()} answered ${res.status}.`); + } + + const data = (await res.json()) as TagsResponse; + const models = (data.models ?? []) + // Older daemons don't report capabilities; only filter when they do. + .filter((m) => !m.capabilities || m.capabilities.includes("completion")) + .map((m) => m.name ?? m.model) + .filter((name): name is string => Boolean(name)) + .filter((name) => !NON_CHAT_MODEL.test(name)); + const unique = [...new Set(models)].sort(); + tagsCache = { at: now, models: unique }; + return unique; +} + +export const ollamaProvider: AiProvider = { + id: "ollama", + label: "Ollama (local)", + get defaultModel(): string { + return process.env.OLLAMA_MODEL || ""; + }, + + async listModels(): Promise { + return fetchTags(); + }, + + async unavailableReason(): Promise { + try { + await fetchTags(); + return null; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, + + async chat(req: ChatRequest): Promise { + let res: Response; + try { + res = await fetchWithTimeout( + `${ollamaBaseUrl()}/api/chat`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: req.model, + messages: [{ role: "system", content: req.system }, ...req.messages], + stream: false, + }), + }, + CHAT_TIMEOUT_MS + ); + } catch { + throw new AiUnavailableError( + `Ollama is not reachable at ${ollamaBaseUrl()} — is the daemon running?` + ); + } + + if (!res.ok) { + const body = await res.text().catch(() => ""); + if (res.status === 404) { + throw new AiUnavailableError( + `Ollama has no model "${req.model}" — pull it with \`ollama pull ${req.model}\`.` + ); + } + throw new Error(`Ollama error ${res.status}${body ? `: ${body}` : ""}`); + } + + const data = (await res.json()) as { message?: { content?: string } }; + return data.message?.content ?? ""; + }, +}; + +/** Exposed for tests only; not part of the provider contract. */ +export function resetOllamaCache(): void { + tagsCache = null; +} diff --git a/server/src/ai/types.ts b/server/src/ai/types.ts new file mode 100644 index 00000000..c0eff13c --- /dev/null +++ b/server/src/ai/types.ts @@ -0,0 +1,47 @@ +/** Shared contracts for the AI backends the assistant can talk to. */ + +/** Claude (Anthropic) stays the default backend; Ollama is the opt-in local one. */ +export type AiProviderId = "anthropic" | "ollama"; + +export type ChatMessage = { role: "user" | "assistant"; content: string }; + +export type ProviderInfo = { + id: AiProviderId; + label: string; + /** Model id used when the client doesn't pick one. */ + defaultModel: string; + /** True when the backend looks usable right now. */ + available: boolean; + /** Why it isn't usable — shown next to the picker. */ + detail?: string; +}; + +export type ChatRequest = { + model: string; + system: string; + messages: ChatMessage[]; +}; + +/** + * Thrown when a backend can't serve the request at all — not configured, + * not running, no credentials. The route turns it into a 503 with a message + * the UI can show verbatim. + */ +export class AiUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "AiUnavailableError"; + } +} + +export interface AiProvider { + readonly id: AiProviderId; + readonly label: string; + /** Model id used when the client doesn't pick one ("" when unknown). */ + readonly defaultModel: string; + /** Model ids this backend can serve, best-effort. */ + listModels(): Promise; + /** null when the backend looks usable, otherwise the reason why not. */ + unavailableReason(): Promise; + chat(req: ChatRequest): Promise; +} diff --git a/server/src/index.ts b/server/src/index.ts index 493d807f..fb8c294e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,7 @@ import cors from "cors"; import { marketRouter } from "./routes/market.js"; import { portfolioRouter } from "./routes/portfolio.js"; import { aiRouter } from "./routes/ai.js"; +import { describeProviders } from "./ai/index.js"; import { allStats } from "./providers/registry.js"; import { requireApiKey } from "./auth.js"; import { rateLimit } from "./rateLimit.js"; @@ -44,12 +45,14 @@ app.use( aiRouter ); -app.get("/api/status", (_req, res) => { +app.get("/api/status", async (_req, res) => { res.json({ ok: true, time: new Date().toISOString(), providers: allStats(), ai: Boolean(process.env.ANTHROPIC_API_KEY), + // Which assistant backends are usable right now (Claude, local Ollama…). + aiProviders: await describeProviders(), }); }); diff --git a/server/src/routes/ai.test.ts b/server/src/routes/ai.test.ts new file mode 100644 index 00000000..41d6facc --- /dev/null +++ b/server/src/routes/ai.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Request, Response } from "express"; +import { aiRouter } from "./ai.js"; +import { pickProvider } from "../ai/index.js"; +import { resetOllamaCache } from "../ai/ollama.js"; + +function fakeRes(): Response { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; +} + +function getHandler(method: string, path: string) { + const layer = (aiRouter as any).stack.find( + (l: any) => l.route?.path === path && l.route?.methods?.[method] + ); + if (!layer) throw new Error(`no route registered for ${method.toUpperCase()} ${path}`); + return layer.route.stack[0].handle as (req: Request, res: Response, next: () => void) => unknown; +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function payload(res: Response): any { + return (res.json as any).mock.calls[0][0]; +} + +beforeEach(() => { + resetOllamaCache(); + delete process.env.OLLAMA_MODEL; +}); + +describe("GET /ai/providers", () => { + it("offers Claude and Ollama, with Claude as the default", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ models: [] }))); + try { + const handler = getHandler("get", "/providers"); + const res = fakeRes(); + + await handler({} as Request, res, () => {}); + + const body = payload(res); + expect(body.defaultProvider).toBe("anthropic"); + expect(body.providers.map((p: any) => p.id)).toEqual(["anthropic", "ollama"]); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe("GET /ai/models", () => { + it("lists the local models and skips the ones that can't chat", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonResponse({ + models: [ + { name: "llama3.2:latest", capabilities: ["completion"] }, + { name: "nomic-embed-text:latest", capabilities: ["embedding"] }, + { name: "whisper-tiny:latest", capabilities: ["completion"] }, + ], + }) + ) + ); + try { + const handler = getHandler("get", "/models"); + const res = fakeRes(); + + await handler({ query: { provider: "ollama" } } as unknown as Request, res, () => {}); + + const body = payload(res); + expect(body.models).toEqual(["llama3.2:latest"]); + expect(body.defaultModel).toBe("llama3.2:latest"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("rejects an unknown provider", async () => { + const handler = getHandler("get", "/models"); + const res = fakeRes(); + + await handler({ query: { provider: "openai" } } as unknown as Request, res, () => {}); + + expect(res.status).toHaveBeenCalledWith(400); + }); +}); + +describe("POST /ai/chat", () => { + it("answers through the selected local model", async () => { + const fetchMock = vi.fn(async (url: any) => { + if (String(url).endsWith("/api/chat")) { + return jsonResponse({ message: { role: "assistant", content: "42" } }); + } + return jsonResponse({ models: [{ name: "llama3.2:latest", capabilities: ["completion"] }] }); + }); + vi.stubGlobal("fetch", fetchMock); + try { + const handler = getHandler("post", "/chat"); + const res = fakeRes(); + + await handler( + { + body: { + messages: [{ role: "user", content: "hi" }], + provider: "ollama", + model: "llama3.2:latest", + }, + } as unknown as Request, + res, + () => {} + ); + + expect(payload(res)).toEqual({ + text: "42", + provider: "ollama", + model: "llama3.2:latest", + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("rejects an unknown provider before calling any backend", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + try { + const handler = getHandler("post", "/chat"); + const res = fakeRes(); + + await handler( + { body: { messages: [{ role: "user", content: "hi" }], provider: "openai" } } as unknown as Request, + res, + () => {} + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("requires at least one message", async () => { + const handler = getHandler("post", "/chat"); + const res = fakeRes(); + + await handler({ body: { messages: [] } } as unknown as Request, res, () => {}); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("reports an unreachable Ollama as unavailable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }) + ); + try { + const handler = getHandler("post", "/chat"); + const res = fakeRes(); + + await handler( + { + body: { + messages: [{ role: "user", content: "hi" }], + provider: "ollama", + model: "llama3.2:latest", + }, + } as unknown as Request, + res, + () => {} + ); + + expect(res.status).toHaveBeenCalledWith(503); + expect(payload(res).error).toMatch(/not reachable/i); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe("pickProvider", () => { + it("falls back to Claude when the client doesn't name a backend", () => { + expect(pickProvider(undefined)).toBe("anthropic"); + expect(pickProvider("")).toBe("anthropic"); + }); + + it("keeps a known backend and rejects anything else", () => { + expect(pickProvider("ollama")).toBe("ollama"); + expect(pickProvider("anthropic")).toBe("anthropic"); + expect(pickProvider("openai")).toBeNull(); + }); +}); diff --git a/server/src/routes/ai.ts b/server/src/routes/ai.ts index f67f46dd..bb094a16 100644 --- a/server/src/routes/ai.ts +++ b/server/src/routes/ai.ts @@ -1,50 +1,111 @@ import { Router } from "express"; -import Anthropic from "@anthropic-ai/sdk"; +import { + DEFAULT_PROVIDER_ID, + describeProviders, + getProvider, + isProviderId, + pickProvider, +} from "../ai/index.js"; +import { AiUnavailableError, type AiProvider, type ChatMessage } from "../ai/types.js"; export const aiRouter = Router(); -// Uses ANTHROPIC_API_KEY (or an `ant auth login` profile) from the environment. -// If no credentials are configured, the endpoint reports the assistant as unavailable. -let client: Anthropic | null = null; -function getClient(): Anthropic { - if (!client) client = new Anthropic(); - return client; -} - const SYSTEM = `You are the AI assistant inside OpenTerminal, a Bloomberg-style financial terminal. You help the user interpret market data, charts, news, options chains and macro indicators. Answer concisely and professionally, in the language the user writes in. When market data is provided in the conversation as JSON context, ground your answer in it. You are not a licensed financial advisor: never give personalized investment advice or tell the user what to buy or sell.`; +type ChatBody = { + messages?: unknown; + context?: unknown; + provider?: unknown; + model?: unknown; +}; + +/** Keep only well-formed turns — the backends reject anything else. */ +function normalizeMessages(input: unknown): ChatMessage[] { + if (!Array.isArray(input)) return []; + const out: ChatMessage[] = []; + for (const raw of input) { + if (!raw || typeof raw !== "object") continue; + const { role, content } = raw as { role?: unknown; content?: unknown }; + if (role !== "user" && role !== "assistant") continue; + if (typeof content !== "string" || content.length === 0) continue; + out.push({ role, content }); + } + return out; +} + +/** The requested model, or the backend's default, or its first available one. */ +async function resolveModel(provider: AiProvider, requested: unknown): Promise { + const wanted = typeof requested === "string" ? requested.trim() : ""; + if (wanted) return wanted; + if (provider.defaultModel) return provider.defaultModel; + const models = await provider.listModels(); + return models[0] ?? ""; +} + +/** Catalogue for the widget's picker: which backends exist, and which work. */ +aiRouter.get("/providers", async (_req, res) => { + res.json({ + providers: await describeProviders(), + defaultProvider: DEFAULT_PROVIDER_ID, + }); +}); + +/** Model ids the given backend can serve right now. */ +aiRouter.get("/models", async (req, res) => { + const requested = typeof req.query.provider === "string" ? req.query.provider : DEFAULT_PROVIDER_ID; + if (!isProviderId(requested)) { + return res.status(400).json({ error: `unknown provider "${requested}"` }); + } + const provider = getProvider(requested); + try { + const models = await provider.listModels(); + res.json({ + provider: requested, + models, + defaultModel: provider.defaultModel || models[0] || "", + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + res.status(err instanceof AiUnavailableError ? 503 : 502).json({ error: msg }); + } +}); + aiRouter.post("/chat", async (req, res) => { - const { messages, context } = req.body ?? {}; - if (!Array.isArray(messages) || messages.length === 0) { + const { messages, context, provider: providerId, model } = (req.body ?? {}) as ChatBody; + const turns = normalizeMessages(messages); + if (turns.length === 0) { return res.status(400).json({ error: "messages array required" }); } + + // Claude stays the default; the client only has to name a provider to switch. + const id = pickProvider(providerId); + if (!id) { + return res.status(400).json({ error: `unknown provider "${String(providerId)}"` }); + } + const provider = getProvider(id); + try { - const contextBlock = context - ? [{ role: "user" as const, content: `Current terminal context (JSON):\n${JSON.stringify(context)}` }] + const resolvedModel = await resolveModel(provider, model); + if (!resolvedModel) { + return res.status(400).json({ error: `no model available for provider "${id}"` }); + } + const contextBlock: ChatMessage[] = context + ? [{ role: "user", content: `Current terminal context (JSON):\n${JSON.stringify(context)}` }] : []; - const response = await getClient().messages.create({ - model: process.env.ANTHROPIC_MODEL ?? "claude-opus-4-8", - max_tokens: 16000, - thinking: { type: "adaptive" }, + const text = await provider.chat({ + model: resolvedModel, system: SYSTEM, - messages: [...contextBlock, ...messages], + messages: [...contextBlock, ...turns], }); - if (response.stop_reason === "refusal") { - return res.json({ text: "The assistant declined to answer this request." }); - } - const text = response.content - .filter((b) => b.type === "text") - .map((b) => ("text" in b ? b.text : "")) - .join(""); - res.json({ text }); + res.json({ text, provider: id, model: resolvedModel }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("api_key") || msg.includes("authentication")) { - return res.status(503).json({ error: "AI assistant unavailable: set ANTHROPIC_API_KEY on the server." }); + if (err instanceof AiUnavailableError) { + return res.status(503).json({ error: msg }); } res.status(502).json({ error: msg }); } diff --git a/web/components/TopBar.tsx b/web/components/TopBar.tsx index c29ab1ea..1a46f239 100644 --- a/web/components/TopBar.tsx +++ b/web/components/TopBar.tsx @@ -9,6 +9,7 @@ type Status = { ok: boolean; providers: Array<{ name: string; ok: number; failed: number; lastLatencyMs: number | null }>; ai: boolean; + aiProviders?: Array<{ id: string; label: string; available: boolean; detail?: string }>; }; function Clock({ tz, label }: { tz: string; label: string }) { @@ -48,6 +49,10 @@ export default function TopBar() { const market = marketStateNY(); const healthy = status?.providers.filter((p) => p.ok > 0) ?? []; + // The assistant is up if any backend is usable — Claude or a local Ollama. + const aiUp = status?.aiProviders + ? status.aiProviders.some((p) => p.available) + : Boolean(status?.ai); return (
@@ -69,7 +74,7 @@ export default function TopBar() { ? healthy.map((p) => `${p.name} ${p.lastLatencyMs ?? "—"}ms`).join(" · ") : "connecting…"} - AI {status?.ai ? "●" : "○"} + AI {aiUp ? "●" : "○"}
); } diff --git a/web/components/widgets/AiWidget.tsx b/web/components/widgets/AiWidget.tsx index 6ca4b7cc..c3c48f51 100644 --- a/web/components/widgets/AiWidget.tsx +++ b/web/components/widgets/AiWidget.tsx @@ -1,18 +1,60 @@ "use client"; -import { useMutation } from "@tanstack/react-query"; -import { useRef, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; import { apiGet, apiPost, type Quote } from "../../lib/api"; -import { useTerminal } from "../../store/terminal"; +import { useTerminal, type AiProviderId } from "../../store/terminal"; type Msg = { role: "user" | "assistant"; content: string }; +type ProviderInfo = { + id: AiProviderId; + label: string; + defaultModel: string; + available: boolean; + detail?: string; +}; + export default function AiWidget() { const activeSymbol = useTerminal((s) => s.activeSymbol); + const provider = useTerminal((s) => s.aiProvider); + const model = useTerminal((s) => s.aiModel); + const setProvider = useTerminal((s) => s.setAiProvider); + const setModel = useTerminal((s) => s.setAiModel); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const scrollRef = useRef(null); + const providersQuery = useQuery({ + queryKey: ["ai-providers"], + queryFn: () => apiGet<{ providers: ProviderInfo[]; defaultProvider: string }>("/api/ai/providers"), + staleTime: 60_000, + }); + + const modelsQuery = useQuery({ + queryKey: ["ai-models", provider], + queryFn: () => + apiGet<{ provider: string; models: string[]; defaultModel: string }>( + `/api/ai/models?provider=${encodeURIComponent(provider)}` + ), + staleTime: 60_000, + }); + + const providers = providersQuery.data?.providers ?? []; + const models = useMemo(() => modelsQuery.data?.models ?? [], [modelsQuery.data]); + const providerInfo = providers.find((p) => p.id === provider); + + // Keep the picked model valid for the selected backend: fall back to the + // backend's default (or its first model) when it no longer offers the + // current pick — e.g. after switching from Claude to a local model. + useEffect(() => { + if (models.length === 0) return; + if (model && models.includes(model)) return; + const preferred = modelsQuery.data?.defaultModel; + const next = preferred && models.includes(preferred) ? preferred : models[0]; + if (next) setModel(next); + }, [models, modelsQuery.data, model, setModel]); + const chat = useMutation({ mutationFn: async (userText: string) => { let context: unknown = null; @@ -22,7 +64,12 @@ export default function AiWidget() { // context is best-effort } const next = [...messages, { role: "user" as const, content: userText }]; - const res = await apiPost<{ text: string }>("/api/ai/chat", { messages: next, context }); + const res = await apiPost<{ text: string }>("/api/ai/chat", { + messages: next, + context, + provider, + model, + }); return { next, reply: res.text }; }, onSuccess: ({ next, reply }) => { @@ -39,8 +86,62 @@ export default function AiWidget() { chat.mutate(text); }; + const reload = () => { + providersQuery.refetch(); + modelsQuery.refetch(); + }; + return (
+
+ + + +
+ {providerInfo && !providerInfo.available && ( +
+ {providerInfo.detail} +
+ )} + {modelsQuery.isError && ( +
+ {(modelsQuery.error as Error).message} +
+ )}
{messages.length === 0 && (
diff --git a/web/store/terminal.ts b/web/store/terminal.ts index 46d3a99e..421f2e15 100644 --- a/web/store/terminal.ts +++ b/web/store/terminal.ts @@ -30,12 +30,17 @@ export type WidgetInstance = { export type LayoutItem = { i: string; x: number; y: number; w: number; h: number }; +/** Which assistant backend the AI widget talks to. Claude is the default. */ +export type AiProviderId = "anthropic" | "ollama"; + type TerminalState = { activeSymbol: string; widgets: WidgetInstance[]; layout: LayoutItem[]; watchlist: string[]; commandOpen: boolean; + aiProvider: AiProviderId; + aiModel: string; setActiveSymbol: (s: string) => void; setCommandOpen: (open: boolean) => void; addWidget: (type: WidgetType, symbol?: string) => void; @@ -46,6 +51,8 @@ type TerminalState = { addToWatchlist: (s: string) => void; removeFromWatchlist: (s: string) => void; resetWorkspace: () => void; + setAiProvider: (p: AiProviderId) => void; + setAiModel: (m: string) => void; }; const DEFAULT_WIDGETS: WidgetInstance[] = [ @@ -90,6 +97,8 @@ export const useTerminal = create()( layout: DEFAULT_LAYOUT, watchlist: ["AAPL", "MSFT", "NVDA", "TSLA", "AMZN", "GOOGL", "META", "SPY"], commandOpen: false, + aiProvider: "anthropic", + aiModel: "", setActiveSymbol: (s) => { const sym = normalizeSymbol(s); if (sym) set({ activeSymbol: sym }); @@ -131,6 +140,8 @@ export const useTerminal = create()( }, removeFromWatchlist: (s) => set((st) => ({ watchlist: st.watchlist.filter((x) => x !== s) })), resetWorkspace: () => set({ widgets: DEFAULT_WIDGETS, layout: DEFAULT_LAYOUT }), + setAiProvider: (p) => set({ aiProvider: p }), + setAiModel: (m) => set({ aiModel: m }), }), { name: "openterminal-workspace" } )