KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module
This commit is contained in:
+115
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
+423
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user