KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module

This commit is contained in:
k
2026-09-21 11:00:32 +02:00
commit 739c14324a
47 changed files with 8263 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { config } from "./config.ts";
import {
createSession,
deleteSession,
getDb,
getSession,
getUserById,
getUserByUsername,
toPublicUser,
type PublicUser,
} from "./db.ts";
// Ensure DB + admin row exist at startup.
getDb();
export type { PublicUser };
function sha256hex(s: string): string {
return createHash("sha256").update(s).digest("hex");
}
/** Compare two secrets in constant time (via their hashes so lengths match). */
export function secretsEqual(a: string, bHash: string): boolean {
const aHash = sha256hex(a);
const ab = Buffer.from(aHash, "utf8");
const bb = Buffer.from(bHash, "utf8");
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
export function hashSecret(s: string): string {
return sha256hex(s);
}
/** Fresh random per-account access key (No-KYC: admin hands it to the user). Shown only once. */
export function newAccessKey(): string {
return "ka_" + randomBytes(24).toString("base64url");
}
function checkAdminKey(candidate: string): boolean {
const a = createHash("sha256").update(candidate).digest();
const b = createHash("sha256").update(config.appPassword).digest();
return timingSafeEqual(a, b);
}
/**
* Login with { username, key }.
* - username "admin" authenticates against APP_PASSWORD (.env).
* - other usernames authenticate against their admin-issued access key.
*/
export function login(username: string, key: string): PublicUser | null {
if (username === "admin") {
if (!checkAdminKey(key)) return null;
const admin = getUserByUsername("admin");
if (!admin) return null;
return toPublicUser(admin);
}
const u = getUserByUsername(username);
if (!u || !u.key_hash) return null;
if (!secretsEqual(key, u.key_hash)) return null;
return toPublicUser(u);
}
/** Issue a fresh one-time Bearer token per successful login (stored hashed in SQLite). */
export function issueToken(user: PublicUser): { token: string; expiresAt: number } {
const token = randomBytes(32).toString("hex"); // 256-bit
const expiresAt = Date.now() + config.sessionTtlMs;
createSession(sha256hex(token), user.id, expiresAt);
return { token, expiresAt };
}
export function verifyToken(token: string): PublicUser | null {
const s = getSession(sha256hex(token));
if (!s) return null;
const u = getUserById(s.user_id);
if (!u) {
deleteSession(sha256hex(token));
return null;
}
return toPublicUser(u);
}
export function revokeToken(token: string): void {
deleteSession(sha256hex(token));
}
export function extractBearer(req: Request): string | null {
const h = req.headers.get("authorization");
if (!h) return null;
const m = h.match(/^Bearer\s+(.+)$/i);
return m ? m[1]!.trim() : null;
}
// --- sliding-window rate limiter (per IP) for /api/auth/login ---
const attempts = new Map<string, number[]>();
export function isRateLimited(ip: string): boolean {
const now = Date.now();
const windowStart = now - config.rateLimitWindowMs;
const list = (attempts.get(ip) ?? []).filter((t) => t > windowStart);
attempts.set(ip, list);
return list.length >= config.rateLimitMax;
}
export function recordAttempt(ip: string): void {
const list = attempts.get(ip) ?? [];
list.push(Date.now());
attempts.set(ip, list);
}
export function clearAttempts(ip: string): void {
attempts.delete(ip);
}
+47
View File
@@ -0,0 +1,47 @@
function env(name: string, fallback = ""): string {
return process.env[name] ?? fallback;
}
function envBool(name: string, fallback: boolean): boolean {
const v = process.env[name];
if (v === undefined) return fallback;
return v === "1" || v.toLowerCase() === "true";
}
function envInt(name: string, fallback: number): number {
const v = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(v) ? v : fallback;
}
export const config = {
port: envInt("PORT", 3000),
host: env("HOST", "127.0.0.1"),
appPassword: env("APP_PASSWORD", ""),
sessionTtlMs: envInt("SESSION_TTL_HOURS", 720) * 3600_000,
dataDir: env("DATA_DIR", "./data"),
rateLimitMax: envInt("RATE_LIMIT_MAX", 5),
rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MIN", 10) * 60_000,
ollamaHost: env("OLLAMA_HOST", "http://localhost:11434"),
ollamaEnabled: envBool("OLLAMA_ENABLED", true),
mistralApiKey: env("MISTRAL_API_KEY", ""),
mistralEnabled: envBool("MISTRAL_ENABLED", false),
deepseekApiKey: env("DEEPSEEK_API_KEY", ""),
deepseekEnabled: envBool("DEEPSEEK_ENABLED", false),
anthropicApiKey: env("ANTHROPIC_API_KEY", ""),
anthropicEnabled: envBool("ANTHROPIC_ENABLED", false),
openaiApiKey: env("OPENAI_API_KEY", ""),
openaiEnabled: envBool("OPENAI_ENABLED", false),
};
export function assertConfig(): void {
if (!config.appPassword) {
console.error("FATAL: APP_PASSWORD is not set. Copy .env.example to .env and set it.");
process.exit(1);
}
}
+317
View File
@@ -0,0 +1,317 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { config } from "./config.ts";
// --- row types ---
export interface UserRow {
id: string;
username: string;
display_name: string;
avatar_url: string;
theme: string;
key_hash: string;
created_at: number;
}
export interface ConversationRow {
id: string;
user_id: string;
title: string;
topic: string;
model: string;
created_at: number;
updated_at: number;
}
export interface MessageRow {
id: number;
conv_id: string;
role: string;
content: string;
created_at: number;
}
export interface SessionRow {
token_hash: string;
user_id: string;
created_at: number;
expires_at: number;
}
export interface PublicUser {
id: string;
username: string;
displayName: string;
avatarUrl: string;
theme: string;
createdAt: number;
isAdmin: boolean;
}
// --- db handle ---
let db: Database | null = null;
export function getDb(): Database {
if (db) return db;
mkdirSync(config.dataDir, { recursive: true });
db = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS users(
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'auto',
key_hash TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New chat',
topic TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages(
id INTEGER PRIMARY KEY AUTOINCREMENT,
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
CREATE TABLE IF NOT EXISTS shares(
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
public_id TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
`);
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
if (!admin) {
db.query(
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(randomUUID(), "admin", "Administrator", "", "auto", "", Date.now());
}
return db;
}
export function toPublicUser(u: UserRow): PublicUser {
return {
id: u.id,
username: u.username,
displayName: u.display_name,
avatarUrl: u.avatar_url,
theme: u.theme,
createdAt: u.created_at,
isAdmin: u.username === "admin",
};
}
// --- users ---
export function getUserByUsername(username: string): UserRow | null {
return getDb().query("SELECT * FROM users WHERE username = ?").get(username) as UserRow | null;
}
export function getUserById(id: string): UserRow | null {
return getDb().query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null;
}
export function listUsers(): UserRow[] {
return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[];
}
export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; keyHash: string }): UserRow {
const d = getDb();
const row: UserRow = {
id: randomUUID(),
username: opts.username,
display_name: opts.displayName,
avatar_url: opts.avatarUrl,
theme: opts.theme,
key_hash: opts.keyHash,
created_at: Date.now(),
};
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.key_hash, row.created_at,
);
return row;
}
export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; keyHash?: string }): UserRow | null {
const d = getDb();
const sets: string[] = [];
const vals: (string | number)[] = [];
if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); }
if (patch.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); }
if (patch.theme !== undefined) { sets.push("theme = ?"); vals.push(patch.theme); }
if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
if (sets.length > 0) {
d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
}
return getUserById(id);
}
export function deleteUser(id: string): void {
const d = getDb();
const convs = d.query("SELECT id FROM conversations WHERE user_id = ?").all(id) as { id: string }[];
const delMsg = d.query("DELETE FROM messages WHERE conv_id = ?");
const delShare = d.query("DELETE FROM shares WHERE conv_id = ?");
const delConv = d.query("DELETE FROM conversations WHERE id = ?");
const txn = d.transaction((cid: string) => {
delMsg.run(cid);
delShare.run(cid);
delConv.run(cid);
});
for (const c of convs) txn(c.id);
d.query("DELETE FROM sessions WHERE user_id = ?").run(id);
d.query("DELETE FROM users WHERE id = ?").run(id);
}
// --- sessions ---
export function createSession(tokenHash: string, userId: string, expiresAt: number): void {
const now = Date.now();
getDb().query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(tokenHash, userId, now, expiresAt);
}
export function getSession(tokenHash: string): SessionRow | null {
const d = getDb();
const s = d.query("SELECT * FROM sessions WHERE token_hash = ?").get(tokenHash) as SessionRow | null;
if (!s) return null;
if (s.expires_at <= Date.now()) {
d.query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
return null;
}
return s;
}
export function deleteSession(tokenHash: string): void {
getDb().query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
}
export function deleteUserSessions(userId: string): void {
getDb().query("DELETE FROM sessions WHERE user_id = ?").run(userId);
}
// --- conversations ---
export function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): ConversationRow {
const now = Date.now();
const row: ConversationRow = {
id: randomUUID(),
user_id: userId,
title: opts.title?.slice(0, 120) || "New chat",
topic: opts.topic?.slice(0, 120) || "",
model: opts.model?.slice(0, 160) || "",
created_at: now,
updated_at: now,
};
getDb().query(
"INSERT INTO conversations (id, user_id, title, topic, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.user_id, row.title, row.topic, row.model, row.created_at, row.updated_at);
return row;
}
export function listConversations(userId: string): ConversationRow[] {
return getDb().query("SELECT * FROM conversations WHERE user_id = ? ORDER BY updated_at DESC").all(userId) as ConversationRow[];
}
export function getConversation(id: string): ConversationRow | null {
return getDb().query("SELECT * FROM conversations WHERE id = ?").get(id) as ConversationRow | null;
}
export function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): ConversationRow | null {
const d = getDb();
const sets: string[] = ["updated_at = ?"];
const vals: (string | number)[] = [Date.now()];
if (patch.title !== undefined) { sets.push("title = ?"); vals.push(patch.title.slice(0, 120) || "New chat"); }
if (patch.topic !== undefined) { sets.push("topic = ?"); vals.push(patch.topic.slice(0, 120)); }
if (patch.model !== undefined) { sets.push("model = ?"); vals.push(patch.model.slice(0, 160)); }
d.query(`UPDATE conversations SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
return getConversation(id);
}
export function touchConversation(id: string, model?: string): void {
if (model) {
getDb().query("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?").run(Date.now(), model.slice(0, 160), id);
} else {
getDb().query("UPDATE conversations SET updated_at = ? WHERE id = ?").run(Date.now(), id);
}
}
export function deleteConversation(id: string): void {
const d = getDb();
const txn = d.transaction(() => {
d.query("DELETE FROM messages WHERE conv_id = ?").run(id);
d.query("DELETE FROM shares WHERE conv_id = ?").run(id);
d.query("DELETE FROM conversations WHERE id = ?").run(id);
});
txn();
}
export function addMessage(convId: string, role: string, content: string): void {
getDb().query("INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)").run(convId, role, content, Date.now());
}
export function getMessages(convId: string): MessageRow[] {
return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[];
}
// --- public shares ---
export function ensureShare(convId: string): string {
const d = getDb();
const existing = d.query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
if (existing) return existing.public_id;
for (let i = 0; i < 5; i++) {
const publicId = randomBytes(9).toString("base64url");
try {
d.query("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)").run(convId, publicId, Date.now());
return publicId;
} catch {
// collision — retry
}
}
throw new Error("could not generate share id");
}
export function getShareByConv(convId: string): string | null {
const r = getDb().query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
return r?.public_id ?? null;
}
export function deleteShare(convId: string): void {
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId);
}
export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; sharedAt: number } | null {
const d = getDb();
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
if (!s) return null;
const conv = getConversation(s.conv_id);
if (!conv) return null;
const author = getUserById(conv.user_id);
return {
conv,
messages: getMessages(conv.id).filter((m) => m.role !== "system"),
authorName: author?.display_name || "Someone",
sharedAt: s.created_at,
};
}
+99
View File
@@ -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();
}
}
}
+54
View File
@@ -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"],
});
}
}
+80
View File
@@ -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();
}
}
}
+27
View File
@@ -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;
}
}
+66
View File
@@ -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 };
}
}
+42
View File
@@ -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";
}
}
+423
View File
@@ -0,0 +1,423 @@
import { serveStatic } from "./static.ts";
import { assertConfig, config } from "./config.ts";
import {
clearAttempts,
extractBearer,
hashSecret,
isRateLimited,
issueToken,
login,
newAccessKey,
recordAttempt,
revokeToken,
verifyToken,
type PublicUser,
} from "./auth.ts";
import {
addMessage,
createConversation,
createUser,
deleteConversation,
deleteShare,
deleteUser,
deleteUserSessions,
ensureShare,
getConversation,
getMessages,
getShareByConv,
getShareByPublic,
getUserById,
getUserByUsername,
listConversations,
listUsers,
toPublicUser,
touchConversation,
updateConversation,
updateUser,
} from "./db.ts";
import type { ChatMessage } from "./drivers/types.ts";
import { DriverRegistry } from "./drivers/registry.ts";
assertConfig();
const registry = new DriverRegistry();
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string {
try {
const info = server.requestIP?.(req);
if (info?.address) return info.address;
} catch {
// ignore
}
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
}
function requireAuth(req: Request): PublicUser | null {
const token = extractBearer(req);
if (!token) return null;
return verifyToken(token);
}
function validMessages(v: unknown): v is ChatMessage[] {
if (!Array.isArray(v) || v.length === 0 || v.length > 200) return false;
return v.every(
(m) =>
m &&
typeof m === "object" &&
(m.role === "user" || m.role === "assistant" || m.role === "system") &&
typeof m.content === "string" &&
m.content.length <= 100_000,
);
}
function cleanStr(v: unknown, max: number): string | null {
if (typeof v !== "string") return null;
const t = v.trim();
if (t.length === 0 || t.length > max) return null;
return t;
}
function validTheme(v: unknown): v is string {
return v === "auto" || v === "light" || v === "dark";
}
function validAvatar(v: unknown): string | null {
if (v === "" || v === undefined || v === null) return "";
if (typeof v !== "string") return null;
const t = v.trim();
if (t.length > 2048) return null;
if (!/^https?:\/\//i.test(t)) return null;
return t;
}
function validUsername(v: unknown): string | null {
if (typeof v !== "string") return null;
const t = v.trim().toLowerCase();
if (!/^[a-z0-9._-]{2,32}$/.test(t) || t === "admin") return null;
return t;
}
async function readJson(req: Request): Promise<{ ok: boolean; body: Record<string, unknown> }> {
try {
return { ok: true, body: (await req.json()) as Record<string, unknown> };
} catch {
return { ok: false, body: {} };
}
}
const server = Bun.serve({
port: config.port,
hostname: config.host,
async fetch(req, server) {
const url = new URL(req.url);
const path = url.pathname;
// --- public: shared chat page data (no auth) ---
const shareMatch = path.match(/^\/api\/share\/([A-Za-z0-9_-]{6,64})$/);
if (shareMatch && req.method === "GET") {
const s = getShareByPublic(shareMatch[1]!);
if (!s) return json({ error: "share not found" }, 404);
return json({
title: s.conv.title,
topic: s.conv.topic,
model: s.conv.model,
authorName: s.authorName,
sharedAt: s.sharedAt,
messages: s.messages.map((m) => ({ role: m.role, content: m.content })),
});
}
// --- auth: login (rate limited) ---
if (path === "/api/auth/login" && req.method === "POST") {
const ip = clientIp(req, server);
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
const { ok, body } = await readJson(req);
if (!ok || typeof body.username !== "string" || typeof body.key !== "string") {
recordAttempt(ip);
return json({ error: 'expected { username, key }' }, 400);
}
const user = login(body.username.trim().toLowerCase(), body.key);
if (!user) {
recordAttempt(ip);
await Bun.sleep(400);
return json({ error: "invalid credentials" }, 401);
}
clearAttempts(ip);
const { token, expiresAt } = issueToken(user);
return json({ token, expiresAt, user });
}
if (path === "/api/auth/verify" && req.method === "GET") {
const user = requireAuth(req);
return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401);
}
if (path === "/api/auth/logout" && req.method === "POST") {
const token = extractBearer(req);
if (token) revokeToken(token);
return json({ ok: true });
}
// --- everything below requires auth ---
if (path.startsWith("/api/")) {
const user = requireAuth(req);
if (!user) return json({ error: "unauthorized" }, 401);
const admin = user.isAdmin;
// --- self profile ---
if (path === "/api/me" && req.method === "GET") return json({ user });
if (path === "/api/me" && req.method === "PATCH") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { displayName?: string; avatarUrl?: string; theme?: string } = {};
if (body.displayName !== undefined) {
const d = cleanStr(body.displayName, 60);
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
patch.displayName = d;
}
if (body.avatarUrl !== undefined) {
const a = validAvatar(body.avatarUrl);
if (a === null) return json({ error: "avatarUrl must be an http(s) URL or empty (e.g. catbox.moe link)" }, 400);
patch.avatarUrl = a;
}
if (body.theme !== undefined) {
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
patch.theme = body.theme;
}
const updated = updateUser(user.id, patch);
if (!updated) return json({ error: "user not found" }, 404);
return json({ user: toPublicUser(updated) });
}
// --- admin: user management (No-KYC accounts, keys issued by admin) ---
if (path === "/api/admin/users" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
return json({ users: listUsers().map(toPublicUser) });
}
if (path === "/api/admin/users" && req.method === "POST") {
if (!admin) return json({ error: "forbidden" }, 403);
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const username = validUsername(body.username);
if (!username) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
if (getUserByUsername(username)) return json({ error: "username taken" }, 409);
const displayName = body.displayName === undefined ? username : cleanStr(body.displayName, 60);
if (displayName === null) return json({ error: "displayName: 1-60 chars" }, 400);
const avatarUrl = body.avatarUrl === undefined ? "" : validAvatar(body.avatarUrl);
if (avatarUrl === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400);
const theme = body.theme === undefined ? "auto" : body.theme;
if (!validTheme(theme)) return json({ error: "theme must be auto|light|dark" }, 400);
const key = newAccessKey();
const created = createUser({ username, displayName, avatarUrl, theme, keyHash: hashSecret(key) });
// Show the raw key exactly once.
return json({ user: toPublicUser(created), key }, 201);
}
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate)?$/);
if (adminUserMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = getUserById(adminUserMatch[1]!);
if (!target || target.username === "admin") return json({ error: "user not found" }, 404);
if (req.method === "DELETE" && !adminUserMatch[2]) {
deleteUser(target.id);
return json({ ok: true });
}
if (req.method === "POST" && adminUserMatch[2] === "/regenerate") {
const key = newAccessKey();
updateUser(target.id, { keyHash: hashSecret(key) });
deleteUserSessions(target.id);
return json({ key });
}
if (req.method === "PATCH" && !adminUserMatch[2]) {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { displayName?: string; avatarUrl?: string; theme?: string } = {};
if (body.displayName !== undefined) {
const d = cleanStr(body.displayName, 60);
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
patch.displayName = d;
}
if (body.avatarUrl !== undefined) {
const a = validAvatar(body.avatarUrl);
if (a === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400);
patch.avatarUrl = a;
}
if (body.theme !== undefined) {
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
patch.theme = body.theme;
}
const updated = updateUser(target.id, patch);
return json({ user: toPublicUser(updated!) });
}
return json({ error: "not found" }, 404);
}
// --- conversations (server-persisted, per account) ---
if (path === "/api/conversations" && req.method === "GET") {
return json({ conversations: listConversations(user.id) });
}
if (path === "/api/conversations" && req.method === "POST") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const conv = createConversation(user.id, {
title: typeof body.title === "string" ? body.title : undefined,
topic: typeof body.topic === "string" ? body.topic : undefined,
model: typeof body.model === "string" ? body.model : undefined,
});
return json({ conversation: conv }, 201);
}
const convMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})(\/(share))?$/);
if (convMatch) {
const conv = getConversation(convMatch[1]!);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
const isShare = convMatch[3] === "share";
if (isShare) {
if (req.method === "POST") {
const publicId = ensureShare(conv.id);
return json({ publicId, url: `/share/${publicId}` }, 201);
}
if (req.method === "GET") {
const publicId = getShareByConv(conv.id);
return publicId ? json({ publicId, url: `/share/${publicId}` }) : json({ shared: false });
}
if (req.method === "DELETE") {
deleteShare(conv.id);
return json({ ok: true });
}
return json({ error: "not found" }, 404);
}
if (req.method === "GET") {
return json({ conversation: conv, messages: getMessages(conv.id) });
}
if (req.method === "PATCH") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { title?: string; topic?: string; model?: string } = {};
if (body.title !== undefined) {
if (typeof body.title !== "string" || body.title.length > 120) return json({ error: "title too long" }, 400);
patch.title = body.title.trim();
}
if (body.topic !== undefined) {
if (typeof body.topic !== "string" || body.topic.length > 120) return json({ error: "topic too long" }, 400);
patch.topic = body.topic.trim();
}
if (body.model !== undefined) {
if (typeof body.model !== "string" || body.model.length > 160) return json({ error: "model too long" }, 400);
patch.model = body.model;
}
return json({ conversation: updateConversation(conv.id, patch) });
}
if (req.method === "DELETE") {
deleteConversation(conv.id);
return json({ ok: true });
}
return json({ error: "not found" }, 404);
}
if (path === "/api/models" && req.method === "GET") {
const models = await registry.listAllModels();
return json({ models });
}
if (path === "/api/chat" && req.method === "POST") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
if (typeof body.model !== "string" || !validMessages(body.messages)) {
return json({ error: 'expected { model: "driver:model", messages: [{role, content}] }' }, 400);
}
let driver, model: string;
try {
({ driver, model } = registry.splitId(body.model));
} catch (e) {
return json({ error: (e as Error).message }, 400);
}
const messages = body.messages as ChatMessage[];
// Optional persistence into a server-side conversation (must belong to the user).
let conv = null;
if (body.conversationId !== undefined) {
if (typeof body.conversationId !== "string") return json({ error: "bad conversationId" }, 400);
conv = getConversation(body.conversationId);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
// Persist the new user message (dedupe against last stored one).
const lastUser = [...messages].reverse().find((m) => m.role === "user");
const stored = getMessages(conv.id);
const lastStored = stored[stored.length - 1];
if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) {
addMessage(conv.id, "user", lastUser.content);
if (conv.title === "New chat") {
updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
}
}
}
const wantStream = body.stream === true || req.headers.get("accept") === "text/event-stream";
const persistReply = (text: string) => {
if (conv && text) {
addMessage(conv.id, "assistant", text);
touchConversation(conv.id, body.model as string);
}
};
if (!wantStream) {
try {
const text = await driver.chat(messages, { model });
persistReply(text);
return json({ model: body.model, message: { role: "assistant", content: text } });
} catch (e) {
return json({ error: (e as Error).message }, 502);
}
}
const stream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
const send = (event: string, data: string) =>
controller.enqueue(enc.encode(`event: ${event}\ndata: ${data}\n\n`));
let full = "";
try {
for await (const token of driver.chatStream(messages, { model })) {
full += token;
send("token", JSON.stringify({ token }));
}
persistReply(full);
send("done", "{}");
} catch (e) {
if (full) persistReply(full + "\n\n[interrupted: " + (e as Error).message + "]");
send("error", JSON.stringify({ error: (e as Error).message }));
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
return json({ error: "not found" }, 404);
}
// --- static frontend (SPA fallback covers /share/:id) ---
return serveStatic(req, url);
},
});
console.log(`KisAssistant listening on http://${config.host}:${config.port}`);
console.log(`Drivers (priority order): ${registry.ordered().map((d) => `${d.name}${d.enabled ? "" : " [disabled]"}`).join(" -> ")}`);
void server;
+38
View File
@@ -0,0 +1,38 @@
import { join } from "node:path";
const PUBLIC_DIR = join(import.meta.dir, "..", "public");
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
};
export async function serveStatic(req: Request, url: URL): Promise<Response> {
if (req.method !== "GET" && req.method !== "HEAD") {
return new Response("not found", { status: 404 });
}
let path = decodeURIComponent(url.pathname);
if (path === "/") path = "/index.html";
// block traversal
if (path.includes("..") || path.includes("\0")) return new Response("not found", { status: 404 });
const file = Bun.file(join(PUBLIC_DIR, path));
if (!(await file.exists())) {
// SPA fallback for non-API routes
if (!path.startsWith("/api/")) {
return new Response(Bun.file(join(PUBLIC_DIR, "index.html")));
}
return new Response("not found", { status: 404 });
}
const ext = path.slice(path.lastIndexOf("."));
return new Response(file, {
headers: {
"Content-Type": MIME[ext] ?? "application/octet-stream",
"Cache-Control": path === "/index.html" ? "no-cache" : "public, max-age=3600",
},
});
}