KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
||||
import { DriverDisabledError } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Base class for OpenAI-compatible HTTP API drivers
|
||||
* (mistral / deepseek / openai / anthropic adapter).
|
||||
* Only enabled when an API key is configured. Phase 1: disabled stubs.
|
||||
*/
|
||||
export abstract class ApiDriverBase implements LLMDriver {
|
||||
abstract readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
protected baseUrl: string;
|
||||
protected apiKey: string;
|
||||
protected defaultModels: string[];
|
||||
|
||||
constructor(opts: { baseUrl: string; apiKey: string; enabled: boolean; defaultModels: string[] }) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
||||
this.apiKey = opts.apiKey;
|
||||
this.enabled = opts.enabled && opts.apiKey.length > 0;
|
||||
this.defaultModels = opts.defaultModels;
|
||||
}
|
||||
|
||||
protected ensureEnabled(): void {
|
||||
if (!this.enabled) throw new DriverDisabledError(this.name);
|
||||
}
|
||||
|
||||
async listModels(): Promise<DriverModel[]> {
|
||||
this.ensureEnabled();
|
||||
// Try /models endpoint, fall back to static list.
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/models`, {
|
||||
headers: { Authorization: `Bearer ${this.apiKey}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { data?: { id: string }[] };
|
||||
if (Array.isArray(data.data) && data.data.length > 0) {
|
||||
return data.data.map((m) => ({ id: `${this.name}:${m.id}`, name: m.id, driver: this.name, label: m.id }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to static list
|
||||
}
|
||||
return this.defaultModels.map((m) => ({ id: `${this.name}:${m}`, name: m, driver: this.name, label: m }));
|
||||
}
|
||||
|
||||
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
|
||||
this.ensureEnabled();
|
||||
let out = "";
|
||||
for await (const t of this.chatStream(messages, opts)) out += t;
|
||||
return out;
|
||||
}
|
||||
|
||||
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
|
||||
this.ensureEnabled();
|
||||
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
|
||||
body: JSON.stringify({ model: opts.model, messages, stream: true }),
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`${this.name} chat failed (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const events = buf.split("\n\n");
|
||||
buf = events.pop() ?? "";
|
||||
for (const ev of events) {
|
||||
for (const line of ev.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("data:")) continue;
|
||||
const payload = t.slice(5).trim();
|
||||
if (payload === "[DONE]") return;
|
||||
try {
|
||||
const obj = JSON.parse(payload) as { choices?: { delta?: { content?: string } }[] };
|
||||
const token = obj.choices?.[0]?.delta?.content ?? "";
|
||||
if (token) {
|
||||
opts.onToken?.(token);
|
||||
yield token;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { config } from "../config.ts";
|
||||
import { ApiDriverBase } from "./apiBase.ts";
|
||||
|
||||
/** Mistral API driver. Global priority #2 (after local ollama). */
|
||||
export class MistralDriver extends ApiDriverBase {
|
||||
readonly name = "mistral";
|
||||
constructor() {
|
||||
super({
|
||||
baseUrl: "https://api.mistral.ai/v1",
|
||||
apiKey: config.mistralApiKey,
|
||||
enabled: config.mistralEnabled,
|
||||
defaultModels: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** DeepSeek API driver. Priority within APIs: DeepSeek first. */
|
||||
export class DeepSeekDriver extends ApiDriverBase {
|
||||
readonly name = "deepseek";
|
||||
constructor() {
|
||||
super({
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
apiKey: config.deepseekApiKey,
|
||||
enabled: config.deepseekEnabled,
|
||||
defaultModels: ["deepseek-chat", "deepseek-reasoner"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Anthropic driver (OpenAI-compatible endpoint). Priority: after DeepSeek. */
|
||||
export class AnthropicDriver extends ApiDriverBase {
|
||||
readonly name = "anthropic";
|
||||
constructor() {
|
||||
super({
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
apiKey: config.anthropicApiKey,
|
||||
enabled: config.anthropicEnabled,
|
||||
defaultModels: ["claude-sonnet-4-20250514", "claude-3-7-sonnet-latest"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenAI API driver. Priority: last among APIs. */
|
||||
export class OpenAIDriver extends ApiDriverBase {
|
||||
readonly name = "openai";
|
||||
constructor() {
|
||||
super({
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: config.openaiApiKey,
|
||||
enabled: config.openaiEnabled,
|
||||
defaultModels: ["gpt-4o", "gpt-4o-mini"],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { config } from "../config.ts";
|
||||
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
||||
|
||||
interface OllamaTag {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Local LLM via Ollama. Priority #1. */
|
||||
export class OllamaDriver implements LLMDriver {
|
||||
readonly name = "ollama";
|
||||
readonly enabled: boolean;
|
||||
|
||||
constructor(private host: string = config.ollamaHost, enabled = config.ollamaEnabled) {
|
||||
this.host = host.replace(/\/$/, "");
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
async listModels(): Promise<DriverModel[]> {
|
||||
const res = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) throw new Error(`ollama /api/tags failed: ${res.status}`);
|
||||
const data = (await res.json()) as { models?: OllamaTag[] };
|
||||
const models = data.models ?? [];
|
||||
return models.map((m) => ({
|
||||
id: `ollama:${m.name}`,
|
||||
name: m.name,
|
||||
driver: "ollama",
|
||||
label: m.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
|
||||
let out = "";
|
||||
for await (const t of this.chatStream(messages, opts)) out += t;
|
||||
return out;
|
||||
}
|
||||
|
||||
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
|
||||
const res = await fetch(`${this.host}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: opts.model, messages, stream: true }),
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`ollama chat failed (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed) as { message?: { content?: string }; error?: string; done?: boolean };
|
||||
if (obj.error) throw new Error(`ollama error: ${obj.error}`);
|
||||
const token = obj.message?.content ?? "";
|
||||
if (token) {
|
||||
opts.onToken?.(token);
|
||||
yield token;
|
||||
}
|
||||
if (obj.done) return;
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) continue; // partial line, ignore
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Puppeteer/headless-browser driver stub (scrapes chatgpt.com).
|
||||
* Intentionally NOT implemented: fragile, ToS-risky, needs a real
|
||||
* account session. Kept as a stub so the Driver interface reserves
|
||||
* its slot at global priority #3.
|
||||
*/
|
||||
export class PuppeteerOpenAIDriver implements LLMDriver {
|
||||
readonly name = "puppeteer-openai";
|
||||
readonly enabled = false;
|
||||
|
||||
async listModels(): Promise<DriverModel[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async chat(_messages: ChatMessage[], _opts: ChatOptions): Promise<string> {
|
||||
throw new Error(
|
||||
'driver "puppeteer-openai" is a stub and is not implemented (headless scraping of chatgpt.com is fragile and against ToS; use ollama or official APIs instead)',
|
||||
);
|
||||
}
|
||||
|
||||
async *chatStream(): AsyncGenerator<string, void, void> {
|
||||
throw new Error('driver "puppeteer-openai" is a stub and is not implemented');
|
||||
void 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AnthropicDriver, DeepSeekDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
|
||||
import { OllamaDriver } from "./ollama.ts";
|
||||
import { PuppeteerOpenAIDriver } from "./puppeteer.ts";
|
||||
import type { DriverModel, LLMDriver } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Global driver priority (per spec):
|
||||
* 1. ollama (local) -> 2. mistral -> 3. puppeteer-openai (stub)
|
||||
* -> 4. deepseek -> 5. anthropic -> 6. openai
|
||||
*/
|
||||
const PRIORITY = ["ollama", "mistral", "puppeteer-openai", "deepseek", "anthropic", "openai"];
|
||||
|
||||
export class DriverRegistry {
|
||||
private drivers: Map<string, LLMDriver>;
|
||||
|
||||
constructor() {
|
||||
const all: LLMDriver[] = [
|
||||
new OllamaDriver(),
|
||||
new MistralDriver(),
|
||||
new PuppeteerOpenAIDriver(),
|
||||
new DeepSeekDriver(),
|
||||
new AnthropicDriver(),
|
||||
new OpenAIDriver(),
|
||||
];
|
||||
this.drivers = new Map(all.map((d) => [d.name, d]));
|
||||
}
|
||||
|
||||
/** Drivers in priority order (enabled ones only if flag set). */
|
||||
ordered(onlyEnabled = false): LLMDriver[] {
|
||||
const list = PRIORITY.map((n) => this.drivers.get(n)!).filter(Boolean);
|
||||
return onlyEnabled ? list.filter((d) => d.enabled) : list;
|
||||
}
|
||||
|
||||
get(name: string): LLMDriver | undefined {
|
||||
return this.drivers.get(name);
|
||||
}
|
||||
|
||||
/** List models across all enabled drivers. Never throws: a failing driver is skipped. */
|
||||
async listAllModels(): Promise<DriverModel[]> {
|
||||
const out: DriverModel[] = [];
|
||||
for (const d of this.ordered(true)) {
|
||||
try {
|
||||
out.push(...(await d.listModels()));
|
||||
} catch (e) {
|
||||
console.error(`[drivers] ${d.name} listModels failed:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a fully-qualified "driver:model" id.
|
||||
* "ollama:llama3.1:8b" -> driver "ollama", model "llama3.1:8b".
|
||||
*/
|
||||
splitId(fullId: string): { driver: LLMDriver; model: string } {
|
||||
const idx = fullId.indexOf(":");
|
||||
if (idx === -1) throw new Error(`invalid model id "${fullId}" (expected "driver:model")`);
|
||||
const driverName = fullId.slice(0, idx);
|
||||
const model = fullId.slice(idx + 1);
|
||||
const driver = this.drivers.get(driverName);
|
||||
if (!driver) throw new Error(`unknown driver "${driverName}"`);
|
||||
if (!driver.enabled) throw new Error(`driver "${driverName}" is disabled`);
|
||||
if (!model) throw new Error(`empty model name in "${fullId}"`);
|
||||
return { driver, model };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DriverModel {
|
||||
/** Fully-qualified id, e.g. "ollama:llama3.1" */
|
||||
id: string;
|
||||
/** Short model name, e.g. "llama3.1" */
|
||||
name: string;
|
||||
/** Driver name, e.g. "ollama" */
|
||||
driver: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
model: string; // short name within the driver
|
||||
signal?: AbortSignal;
|
||||
onToken?: (token: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver interface (class-oriented as per spec).
|
||||
* Each LLM backend (ollama, mistral, puppeteer, ...) implements this.
|
||||
*/
|
||||
export interface LLMDriver {
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
/** List models exposed by this driver. */
|
||||
listModels(): Promise<DriverModel[]>;
|
||||
/** Non-streaming chat completion. */
|
||||
chat(messages: ChatMessage[], opts: ChatOptions): Promise<string>;
|
||||
/** Streaming chat completion (SSE tokens). */
|
||||
chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void>;
|
||||
}
|
||||
|
||||
export class DriverDisabledError extends Error {
|
||||
constructor(driver: string) {
|
||||
super(`driver "${driver}" is disabled / not configured`);
|
||||
this.name = "DriverDisabledError";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user