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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user