diff --git a/CHANGELOG.md b/CHANGELOG.md index bc529f2..2f05d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog +## 0.8.1 - 2025-06-27 + +### Fixes + +- Reverted MSI removal – it's causing the Windows build to fail. Unsure why. + +## 0.8.0 - 2025-06-27 + +### New Features + +- Ability to rename MCP servers + +### Improvements + +- Improved error handling a bit, when adding MCP servers + +### Misc + +- Removed MSI Windows installer + +> [!IMPORTANT] +> If you previously installed on Windows via the MSI installer, you MUST +> reinstall using the `.exe` + +## 0.7.0 - 2025-06-13 + +### New Features + +- Customizable System Prompt (in settings) + +### Fixes + +- Correctly handle shell arguments with quotes + +### Refactors + +- Refactored models to be sane. They're now just instances of classes. No more + "static everything". + +### Fixes + +- Fixes false negetive issue when validating Engines + +## 0.6.2 - 2025-06-03 + +### Fixes + +- Fixes false negetive issue when validating Engines + +## 0.6.1 - 2025-06-03 + +### Fixes + +- Fixed issue causing infinite errors when Ollama not configured + ## 0.6.0 - 2025-05-30 ### New Features diff --git a/README.md b/README.md index 7f18159..1426ca0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ https://github.com/user-attachments/assets/0775d100-3eba-4219-9e2f-360a01f28cce ## Quickstart 1. Install [Tome](https://github.com/runebookai/tome/releases) -2. Connect your preferred LLM provider +2. Connect your preferred LLM provider - OpenAI, Ollama and Gemini are preset but you can also add providers like LM Studio by using http://localhost:1234/v1 as the URL 3. Open the MCP tab in Tome and install your first [MCP server](https://github.com/modelcontextprotocol/servers) (Fetch is an easy one to get started with, just paste `uvx mcp-server-fetch` into the server field). 4. Chat with your MCP-powered model! Ask it to fetch the top story on Hacker News. diff --git a/package.json b/package.json index 3c73c1c..65bdcde 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tome", "private": true, - "version": "0.6.0", + "version": "0.8.1", "type": "module", "scripts": { "dev": "vite dev", @@ -66,6 +66,7 @@ "moment": "^2.30.1", "ollama": "^0.5.15", "openai": "^4.98.0", + "shellwords": "^1.0.1", "tailwind-merge": "^3.0.2", "uuid4": "^2.0.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba10d28..402de54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: openai: specifier: ^4.98.0 version: 4.98.0(ws@8.18.2)(zod@3.25.17) + shellwords: + specifier: ^1.0.1 + version: 1.0.1 tailwind-merge: specifier: ^3.0.2 version: 3.0.2 @@ -2236,6 +2239,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shellwords@1.0.1: + resolution: {integrity: sha512-Fd5KAbmR0kf6GL4bYJTeHdSKW1mCu6rMxdZcZ4l/hD9wRpBB6RxA01TdmegXWzIhJARyYDFs8EAdnpAsRaDGWw==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -4628,6 +4634,8 @@ snapshots: shebang-regex@3.0.0: {} + shellwords@1.0.1: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d058acc..81c6a57 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Tome" -version = "0.6.0" +version = "0.8.1" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a56be7e..664bc95 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Tome" -version = "0.6.0" +version = "0.8.1" description = "The easiest way to work with local models and MCP servers." authors = ["Runebook"] license = "MIT" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ce85e22..e202d32 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -76,3 +76,14 @@ pub async fn stop_session(session_id: i32, state: tauri::State<'_, State>) -> Re pub fn restart(app: AppHandle) { app.restart(); } + +#[tauri::command] +pub async fn rename_mcp_server( + session_id: i32, + old_name: String, + new_name: String, + state: tauri::State<'_, State>, +) -> Result<(), String> { + println!("-> rename_mcp_server({}, {} -> {})", session_id, old_name, new_name); + ok_or_err!(mcp::rename_server(session_id, old_name, new_name, state).await) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e201954..32fb511 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -86,6 +86,7 @@ fn main() { commands::call_mcp_tool, commands::start_mcp_server, commands::stop_mcp_server, + commands::rename_mcp_server, // Sessions commands::stop_session, // Misc diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 2b5abfd..0003d34 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -205,3 +205,37 @@ pub async fn peer_info( server.kill()?; Ok(serde_json::to_string(&peer_info)?) } + +pub async fn rename_server( + session_id: i32, + old_name: String, + new_name: String, + state: tauri::State<'_, State>, +) -> Result<()> { + let mut sessions = state.sessions.lock().await; + + if let Some(session) = sessions.get_mut(&session_id) { + if let Some(server) = session.mcp_servers.get_mut(&old_name) { + // Update the server's name + server.set_name(new_name.clone()); + + // Update the tools mapping + let tools_to_update: Vec = session.tools + .iter() + .filter(|(_, server_name)| *server_name == &old_name) + .map(|(tool_name, _)| tool_name.clone()) + .collect(); + + for tool_name in tools_to_update { + session.tools.insert(tool_name, new_name.clone()); + } + + // Move the server to the new name in the map + if let Some(server) = session.mcp_servers.remove(&old_name) { + session.mcp_servers.insert(new_name, server); + } + } + } + + Ok(()) +} diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index 426b936..fc859f9 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -18,6 +18,7 @@ type Service = RunningService; pub struct McpServer { service: Service, pid: Pid, + custom_name: Option, } impl McpServer { @@ -34,11 +35,19 @@ impl McpServer { let proc = McpProcess::start(command, args, env, app)?; let pid = proc.pid(); let service = ().serve(proc).await?; - Ok(Self { service, pid }) + Ok(Self { + service, + pid, + custom_name: None, + }) } pub fn name(&self) -> String { - self.peer_info().server_info.name + self.custom_name.clone().unwrap_or_else(|| self.peer_info().server_info.name) + } + + pub fn set_name(&mut self, new_name: String) { + self.custom_name = Some(new_name); } pub fn peer_info(&self) -> ::PeerInfo { diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index e88f561..92a9073 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -222,5 +222,15 @@ INSERT INTO engines ("name", "type", "options") VALUES "#, kind: MigrationKind::Up, }, + Migration { + version: 14, + description: "add_color_scheme_setting", + sql: r#" +INSERT INTO settings (display, key, value, type) +SELECT 'Color Scheme', 'color-scheme', '"system"', 'select' +WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'color-scheme'); +"#, + kind: MigrationKind::Up, + }, ] } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3eec166..87622dd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -11,7 +11,11 @@ "category": "DeveloperTool", "copyright": "", "createUpdaterArtifacts": true, - "targets": "all", + "targets": [ + "dmg", + "app", + "nsis" + ], "externalBin": [], "icon": [ "icons/32x32.png", @@ -54,7 +58,7 @@ }, "productName": "Tome", "mainBinaryName": "tome", - "version": "0.6.0", + "version": "0.8.1", "identifier": "co.runebook", "plugins": { "sql": { diff --git a/src/app.css b/src/app.css index 0426544..b53e253 100644 --- a/src/app.css +++ b/src/app.css @@ -55,13 +55,65 @@ html, body { - background: #0b0b0b; + background: var(--background-color-dark); font-family: 'Plus Jakarta Sans', sans-serif; font-size: 14px; line-height: 28px; - color: #ffffff; + color: var(--text-color-light); +} + +html[data-theme="light"] { + --color-purple-dark: #7c5fc0; + --color-purple: #7c5fc0; + --color-red: #d7263d; + --color-green: #4caf50; + --color-yellow: #ffd600; + + --border-color-light: #e0e0e0; + --border-color-medium: #f5f5f5; + --border-color-dark: #ffffff; + + --background-color-light: #ffffff; + --background-color-medium: #f5f5f5; + --background-color-dark: #e0e0e0; + + --text-color-light: #222222; + --text-color-medium: #666666; + --text-color-dark: #bbbbbb; +} + +html[data-theme="dark"] { + --color-purple-dark: #9d7cd8; + --color-purple: #bb9af7; + --color-red: #ff757f; + --color-green: #c3e88d; + --color-yellow: #ffc777; + + --border-color-light: #191919; + --border-color-medium: #0c0c0c; + --border-color-dark: #0b0b0b; + + --background-color-light: #191919; + --background-color-medium: #0c0c0c; + --background-color-dark: #0b0b0b; + + --text-color-light: rgba(255, 255, 255, 0.75); + --text-color-medium: #666666; + --text-color-dark: #333333; +} + +html[data-theme="light"], +body[data-theme="light"] { + background: var(--background-color-medium); + color: #222; +} + +html[data-theme="dark"], +body[data-theme="dark"] { + background: #0b0b0b; + color: #fff; } input::placeholder { - color: #ffffff33; + color: var(--text-color-medium); } diff --git a/src/app.d.ts b/src/app.d.ts index 960d27e..61630fa 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -33,14 +33,6 @@ declare global { interface String { ellipsize(length?: number): string; } - - interface CheckboxEvent extends Event { - currentTarget: EventTarget & HTMLInputElement; - } - - interface SpeechRecognitionEvent { - results: SpeechRecognitionResult[]; - } } declare module 'svelte/elements' { diff --git a/src/components/Box.svelte b/src/components/Box.svelte index 550314e..51026fc 100644 --- a/src/components/Box.svelte +++ b/src/components/Box.svelte @@ -7,6 +7,6 @@ const { children, class: cls = '', ...rest }: SvelteHTMLElements['div'] = $props(); - + {@render children?.()} diff --git a/src/components/Button.svelte b/src/components/Button.svelte index bdd0bd0..c4dc1b8 100644 --- a/src/components/Button.svelte +++ b/src/components/Button.svelte @@ -1,13 +1,42 @@ - +{#if waiting && spinner} + +{:else} + +{/if} diff --git a/src/components/Chat.svelte b/src/components/Chat.svelte index 3b1a630..ca6d761 100644 --- a/src/components/Chat.svelte +++ b/src/components/Chat.svelte @@ -1,19 +1,16 @@ -

{server?.name}

+ {#if server?.id} +

{server.name}

+ {:else} +

Add Server

+ {/if} + +

Command

-

Command

-

ENV

- - {#each env, i (i)} + +

ENV

+ +
+ + {#each env, i (i)} + - - - - - {/each} + - - - - -
+
+ {/each} + + + + +
+ + {#if !server?.id} + + + + {#if error} + + + {error} + + {/if} + + {/if}
diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte index 622a586..e8cc39b 100644 --- a/src/components/Menu.svelte +++ b/src/components/Menu.svelte @@ -57,7 +57,7 @@ {#each items as item, i (i)} diff --git a/src/hooks.client.ts b/src/hooks.client.ts index dc19cc4..857dffc 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -6,14 +6,9 @@ import { goto } from '$app/navigation'; import { setupDeeplinks } from '$lib/deeplinks'; import { error } from '$lib/logger'; import { info } from '$lib/logger'; -import App from '$lib/models/app'; -import Config from '$lib/models/config'; -import Engine from '$lib/models/engine'; -import McpServer from '$lib/models/mcp-server'; -import Message from '$lib/models/message'; -import Model from '$lib/models/model'; -import Session from '$lib/models/session'; -import Setting from '$lib/models/setting'; +import { resync } from '$lib/models'; +import Config from '$lib/models/config.svelte'; +import Engine from '$lib/models/engine.svelte'; import startup, { StartupCheck } from '$lib/startup'; import * as toolCallMigration from '$lib/tool-call-migration'; import { isUpToDate } from '$lib/updates'; @@ -25,14 +20,7 @@ export const init: ClientInit = async () => { setupDeeplinks(); info('[green]✔ deeplinks subscribed'); - await App.sync(); - await Session.sync(); - await Message.sync(); - await McpServer.sync(); - await Setting.sync(); - await Config.sync(); - await Engine.sync(); - await Model.sync(); + await resync(); info('[green]✔ database synced'); await toolCallMigration.migrate(); diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index c05e735..17e7779 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -3,17 +3,9 @@ import uuid4 from 'uuid4'; import type { Options } from '$lib/engines/types'; import { error } from '$lib/logger'; -import App from '$lib/models/app'; -import Engine from '$lib/models/engine'; -import type { IMessage } from '$lib/models/message'; -import type { IModel } from '$lib/models/model'; -import Session, { type ISession } from '$lib/models/session'; +import { App, Engine, type IModel, Message, Session } from '$lib/models'; -export async function dispatch( - session: ISession, - model: IModel, - prompt?: string -): Promise { +export async function dispatch(session: Session, model: IModel, prompt?: string): Promise { const app = App.find(session.appId as number); const engine = Engine.find(model.engineId); @@ -28,7 +20,7 @@ export async function dispatch( } if (prompt) { - await Session.addMessage(session, { + await session.addMessage({ role: 'user', content: prompt, }); @@ -41,8 +33,8 @@ export async function dispatch( const message = await engine.client.chat( model, - Session.messages(session), - await Session.tools(session), + session.messages, + await session.tools(), options ); @@ -60,13 +52,13 @@ export async function dispatch( arguments: call.function.arguments, }); - await Session.addMessage(session, { + await session.addMessage({ role: 'assistant', content: '', toolCalls: [call], }); - await Session.addMessage(session, { + await session.addMessage({ role: 'tool', content, toolCallId: call.id, @@ -76,10 +68,9 @@ export async function dispatch( } } - await Session.addMessage(session, { - ...message, - model: model.id, - }); + message.model = model.id; + message.sessionId = session.id; + await message.save(); return message; } diff --git a/src/lib/engines/gemini/client.ts b/src/lib/engines/gemini/client.ts index a99d4bf..6d6ee7b 100644 --- a/src/lib/engines/gemini/client.ts +++ b/src/lib/engines/gemini/client.ts @@ -2,16 +2,16 @@ import { type GenerateContentConfig, GoogleGenAI } from '@google/genai'; import GeminiMessage from '$lib/engines/gemini/message'; import GeminiTools from '$lib/engines/gemini/tool'; -import type { Client, ClientOptions, Options, Tool, ToolCall } from '$lib/engines/types'; -import type { IMessage, IModel } from '$lib/models'; +import type { Client, ClientProps, Options, Tool, ToolCall } from '$lib/engines/types'; +import { type IModel, Message } from '$lib/models'; export default class Gemini implements Client { - private options: ClientOptions; + private options: ClientProps; private client: GoogleGenAI; id = 'gemini'; - constructor(options: ClientOptions) { + constructor(options: ClientProps) { this.options = options; this.client = new GoogleGenAI({ apiKey: options.apiKey, @@ -20,22 +20,34 @@ export default class Gemini implements Client { async chat( model: IModel, - history: IMessage[], + history: Message[], tools?: Tool[], options?: Options - ): Promise { - const messages = history.map(m => GeminiMessage.from(m)).compact(); + ): Promise { + // Extract system messages for system instructions + const systemMessages = history.filter(m => m.role === 'system'); + const nonSystemMessages = history.filter(m => m.role !== 'system'); - let config: GenerateContentConfig = { + // Convert non-system messages to Gemini format + const messages = nonSystemMessages.map(m => GeminiMessage.from(m)).compact(); + + const config: GenerateContentConfig = { temperature: options?.temperature, }; - if (tools && tools.length) { - config = { - tools: GeminiTools.from(tools), + // Add system instruction if system messages exist + if (systemMessages.length > 0) { + // Combine all system messages into one instruction + const systemInstruction = systemMessages.map(m => m.content).join('\n\n'); + config.systemInstruction = { + parts: [{ text: systemInstruction }], }; } + if (tools && tools.length) { + config.tools = GeminiTools.from(tools); + } + const { text, functionCalls } = await this.client.models.generateContent({ model: model.name, contents: messages, @@ -53,17 +65,19 @@ export default class Gemini implements Client { })); } - return { + return Message.new({ model: model.name, name: '', role: 'assistant', content: text || '', toolCalls, - }; + }); } async models(): Promise { - return (await this.client.models.list()).page.map(model => { + return ( + await this.client.models.list({ config: { httpOptions: { timeout: 1000 } } }) + ).page.map(model => { const metadata = model; const name = metadata.name?.replace('models/', '') as string; @@ -71,7 +85,7 @@ export default class Gemini implements Client { id: `gemini:${name}`, name, metadata, - engineId: this.options.engine.id, + engineId: this.options.engineId, supportsTools: true, }; }); @@ -84,12 +98,17 @@ export default class Gemini implements Client { id: `gemini:${name}`, name: displayName as string, metadata, - engineId: this.options.engine.id, + engineId: this.options.engineId, supportsTools: true, }; } async connected(): Promise { - return true; // Assume Gemini is up + try { + await this.client.models.list(); + return true; + } catch { + return false; + } } } diff --git a/src/lib/engines/gemini/message.ts b/src/lib/engines/gemini/message.ts index 921e081..6e78684 100644 --- a/src/lib/engines/gemini/message.ts +++ b/src/lib/engines/gemini/message.ts @@ -1,12 +1,12 @@ import type { Content } from '@google/genai'; -import { type IMessage, Session } from '$lib/models'; +import { Message, Session } from '$lib/models'; export default { from, }; -export function from(message: IMessage): Content | undefined { +export function from(message: Message): Content | undefined { if (message.role == 'user') { return fromUser(message); } else if (message.role == 'assistant' && !message.content) { @@ -16,13 +16,13 @@ export function from(message: IMessage): Content | undefined { } else if (message.role == 'tool') { return fromToolResponse(message); } else if (message.role == 'system') { - return; // Gemini doesn't support System prompts + return; } else { return fromAny(message); } } -function fromUser(message: IMessage): Content { +function fromUser(message: Message): Content { return { role: 'user', parts: [ @@ -33,7 +33,7 @@ function fromUser(message: IMessage): Content { }; } -function fromAssistant(message: IMessage): Content { +function fromAssistant(message: Message): Content { return { role: 'model', parts: [ @@ -44,7 +44,7 @@ function fromAssistant(message: IMessage): Content { }; } -function fromToolCall(message: IMessage): Content | undefined { +function fromToolCall(message: Message): Content | undefined { if (message.toolCalls.length == 0) { return; } @@ -63,11 +63,10 @@ function fromToolCall(message: IMessage): Content | undefined { }; } -function fromToolResponse(message: IMessage): Content { +function fromToolResponse(message: Message): Content { // Find the `toolCall` message for this response const session = Session.find(message.sessionId as number); - const messages = Session.messages(session); - const call = messages.flatMap(m => m.toolCalls).find(tc => tc.id == message.toolCallId); + const call = session.messages.flatMap(m => m.toolCalls).find(tc => tc.id == message.toolCallId); return { role: 'user', @@ -84,7 +83,7 @@ function fromToolResponse(message: IMessage): Content { }; } -function fromAny(message: IMessage): Content { +function fromAny(message: Message): Content { return { role: message.role, parts: [ diff --git a/src/lib/engines/ollama/client.ts b/src/lib/engines/ollama/client.ts index fbfec42..202dc92 100644 --- a/src/lib/engines/ollama/client.ts +++ b/src/lib/engines/ollama/client.ts @@ -1,20 +1,19 @@ import { Ollama as OllamaClient } from 'ollama/browser'; import OllamaMessage from '$lib/engines/ollama/message'; -import type { Client, ClientOptions, Options, Role, Tool } from '$lib/engines/types'; +import type { Client, ClientProps, Options, Role, Tool } from '$lib/engines/types'; import { fetch } from '$lib/http'; -import Message, { type IMessage } from '$lib/models/message'; -import type { IModel } from '$lib/models/model'; +import { type IModel, Message } from '$lib/models'; export default class Ollama implements Client { - private options: ClientOptions; + private options: ClientProps; private client: OllamaClient; message = OllamaMessage; modelRole = 'assistant' as Role; toolRole = 'tool' as Role; - constructor(options: ClientOptions) { + constructor(options: ClientProps) { this.options = options; this.client = new OllamaClient({ host: options.url, @@ -24,10 +23,10 @@ export default class Ollama implements Client { async chat( model: IModel, - history: IMessage[], + history: Message[], tools: Tool[] = [], options: Options = {} - ): Promise { + ): Promise { const messages = history.map(m => this.message.from(m)); const response = await this.client.chat({ model: model.name, @@ -49,7 +48,7 @@ export default class Ollama implements Client { content = content.trim(); } - return Message.default({ + return Message.new({ model: model.name, role: this.modelRole, content, @@ -75,12 +74,16 @@ export default class Ollama implements Client { id: name, name, metadata, - engineId: this.options.engine.id, + engineId: Number(this.options.engineId), supportsTools: capabilities.includes('tools'), }; } async connected(): Promise { - return (await fetch(new URL(this.options.url).origin, { timeout: 200 })).status == 200; + try { + return (await this.models()) && true; + } catch { + return false; + } } } diff --git a/src/lib/engines/ollama/message.ts b/src/lib/engines/ollama/message.ts index c4ae9d6..128db76 100644 --- a/src/lib/engines/ollama/message.ts +++ b/src/lib/engines/ollama/message.ts @@ -1,12 +1,12 @@ -import type { Message } from 'ollama'; +import type { Message as OllamaMessage } from 'ollama'; -import type { IMessage } from '$lib/models/message'; +import { Message } from '$lib/models'; export default { from, }; -export function from(message: IMessage): Message { +export function from(message: Message): OllamaMessage { return { role: message.role, content: message.content, diff --git a/src/lib/engines/openai/client.ts b/src/lib/engines/openai/client.ts index 5d80fd3..3126e7b 100644 --- a/src/lib/engines/openai/client.ts +++ b/src/lib/engines/openai/client.ts @@ -1,18 +1,18 @@ import { OpenAI as OpenAIClient } from 'openai'; +import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/index.mjs'; import OpenAiMessage from '$lib/engines/openai/message'; -import type { Client, ClientOptions, Options, Tool, ToolCall } from '$lib/engines/types'; +import type { Client, ClientProps, Options, Tool, ToolCall } from '$lib/engines/types'; import { fetch } from '$lib/http'; -import type { IMessage } from '$lib/models/message'; -import type { IModel } from '$lib/models/model'; +import { type IModel, Message } from '$lib/models'; export default class OpenAI implements Client { - private options: ClientOptions; + private options: ClientProps; private client: OpenAIClient; id = 'openai'; - constructor(options: ClientOptions) { + constructor(options: ClientProps) { this.options = options; this.client = new OpenAIClient({ apiKey: options.apiKey, @@ -24,17 +24,21 @@ export default class OpenAI implements Client { async chat( model: IModel, - history: IMessage[], + history: Message[], tools: Tool[] = [], - options: Options = {} - ): Promise { + _options: Options = {} + ): Promise { const messages = history.map(m => OpenAiMessage.from(m)); - const response = await this.client.chat.completions.create({ + const completion: ChatCompletionCreateParamsNonStreaming = { model: model.name, messages, - tools, - }); + }; + if (tools.length > 0) { + completion.tools = tools; + } + + const response = await this.client.chat.completions.create(completion); const { role, content, tool_calls } = response.choices[0].message; let toolCalls: ToolCall[] = []; @@ -48,17 +52,17 @@ export default class OpenAI implements Client { })); } - return { + return Message.new({ model: model.name, name: '', role, content: content || '', toolCalls, - }; + }); } async models(): Promise { - return (await this.client.models.list()).data.map(model => { + return (await this.client.models.list({ timeout: 1000 })).data.map(model => { const { id, ...metadata } = model; const name = id.replace('models/', ''); // Gemini model ids are prefixed with "model/" @@ -66,7 +70,7 @@ export default class OpenAI implements Client { id: `${this.id}:${name}`, name, metadata, - engineId: this.options.engine.id, + engineId: this.options.engineId, supportsTools: true, }; }); @@ -79,12 +83,18 @@ export default class OpenAI implements Client { id, name: id, metadata, - engineId: this.options.engine.id, + engineId: this.options.engineId, supportsTools: true, }; } async connected(): Promise { - return (await fetch(new URL(this.options.url).origin, { timeout: 200 })).status == 200; + try { + const resp = await this.client.models.list().asResponse(); + const body = await resp.json(); + return !Object.hasOwn(body, 'error'); + } catch { + return false; + } } } diff --git a/src/lib/engines/openai/message.ts b/src/lib/engines/openai/message.ts index de71bdd..2b8f7c9 100644 --- a/src/lib/engines/openai/message.ts +++ b/src/lib/engines/openai/message.ts @@ -1,12 +1,12 @@ import { OpenAI } from 'openai'; -import type { IMessage } from '$lib/models/message'; +import { Message } from '$lib/models'; export default { from, }; -export function from(message: IMessage): OpenAI.ChatCompletionMessageParam { +export function from(message: Message): OpenAI.ChatCompletionMessageParam { if (message.role == 'assistant') { return fromAssistant(message); } else if (message.role == 'system') { @@ -18,14 +18,14 @@ export function from(message: IMessage): OpenAI.ChatCompletionMessageParam { } } -export function fromUser(message: IMessage): OpenAI.ChatCompletionUserMessageParam { +export function fromUser(message: Message): OpenAI.ChatCompletionUserMessageParam { return { role: 'user', content: message.content, }; } -export function fromAssistant(message: IMessage): OpenAI.ChatCompletionAssistantMessageParam { +export function fromAssistant(message: Message): OpenAI.ChatCompletionAssistantMessageParam { return { role: 'assistant', content: message.content, @@ -33,7 +33,7 @@ export function fromAssistant(message: IMessage): OpenAI.ChatCompletionAssistant }; } -export function fromTool(message: IMessage): OpenAI.ChatCompletionToolMessageParam { +export function fromTool(message: Message): OpenAI.ChatCompletionToolMessageParam { return { tool_call_id: message.toolCallId as string, role: 'tool', @@ -41,14 +41,14 @@ export function fromTool(message: IMessage): OpenAI.ChatCompletionToolMessagePar }; } -export function fromSystem(message: IMessage): OpenAI.ChatCompletionSystemMessageParam { +export function fromSystem(message: Message): OpenAI.ChatCompletionSystemMessageParam { return { role: 'system', content: message.content, }; } -function toolCalls(message: IMessage): OpenAI.ChatCompletionMessageToolCall[] | undefined { +function toolCalls(message: Message): OpenAI.ChatCompletionMessageToolCall[] | undefined { if (message.toolCalls.length == 0) { return; } diff --git a/src/lib/engines/types.ts b/src/lib/engines/types.ts index c9e6a42..740ff6c 100644 --- a/src/lib/engines/types.ts +++ b/src/lib/engines/types.ts @@ -1,18 +1,26 @@ -import type { IEngine, IMessage, IModel } from '$lib/models'; +import { type IModel, Message as TomeMessage } from '$lib/models'; export interface Client { - chat(model: IModel, history: IMessage[], tools?: Tool[], options?: Options): Promise; + chat( + model: IModel, + history: TomeMessage[], + tools?: Tool[], + options?: Options + ): Promise; models(): Promise; info(model: string): Promise; connected(): Promise; } export interface ClientOptions { - engine: IEngine; apiKey: string; url: string; } +export interface ClientProps extends ClientOptions { + engineId: number; +} + export interface Options { num_ctx?: number; temperature?: number; diff --git a/src/lib/llm.ts b/src/lib/llm.ts deleted file mode 100644 index 1343145..0000000 --- a/src/lib/llm.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { IMessage } from './models/message'; - -import { HttpClient, type HttpOptions } from '$lib/http'; -import type { - LlmMessage, - LlmOptions, - LlmTool, - OllamaModel, - OllamaResponse, - OllamaTags, -} from '$lib/llm.d'; -import Setting from '$lib/models/setting'; - -export * from '$lib/llm.d'; - -export class OllamaClient extends HttpClient { - options: HttpOptions = { - timeout: 30000, - headers: { - 'Content-Type': 'application/json', - }, - }; - - get url() { - return Setting.OllamaUrl; - } - - async chat( - model: string, - messages: LlmMessage[], - tools: LlmTool[] = [], - options: LlmOptions = {} - ): Promise { - const body = JSON.stringify({ - model, - messages, - tools, - options, - stream: false, - }); - - const response = (await this.post('/api/chat', { - body, - })) as OllamaResponse; - - let thought: string | undefined; - let content: string = response.message.content - .replace(/\.$/, '') - .replace(/^"/, '') - .replace(/"$/, ''); - - if (content.includes('')) { - [thought, content] = content.split(''); - thought = thought.replace('', '').trim(); - content = content.trim(); - } - - return { - model, - role: 'assistant', - content, - thought, - name: '', - toolCalls: response.message.tool_calls || [], - }; - } - - async list(): Promise { - return ((await this.get('/api/tags')) as OllamaTags).models as OllamaModel[]; - } - - async info(name: string): Promise { - const body = JSON.stringify({ name }); - - return (await this.post('/api/show', { body })) as OllamaModel; - } - - async connected(): Promise { - return ( - ( - (await this.get('', { - raw: true, - timeout: 500, - })) as globalThis.Response - ).status == 200 - ); - } - - async hasModels(): Promise { - if (!(await this.connected())) { - return false; - } - - return ((await this.get('/api/tags')) as OllamaTags).models.length > 0; - } -} diff --git a/src/lib/mcp.d.ts b/src/lib/mcp.d.ts deleted file mode 100644 index f63b1eb..0000000 --- a/src/lib/mcp.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface McpConfig { - command: string; - args: string[]; - env: Record; -} - -export interface McpTool { - name: string; - description: string; - inputSchema: McpInputSchema; -} - -export interface McpInputSchema { - type: string; - title: string; - properties: { [k: string]: any }; // eslint-disable-line - required: string[]; -} diff --git a/src/lib/mcp.ts b/src/lib/mcp.ts index 7147aba..ffb85c7 100644 --- a/src/lib/mcp.ts +++ b/src/lib/mcp.ts @@ -1,16 +1,31 @@ import { invoke } from '@tauri-apps/api/core'; import type { Tool } from '$lib/engines/types'; -import type { McpTool } from '$lib/mcp.d'; -import type { ISession } from '$lib/models/session'; -export * from '$lib/mcp.d'; +export interface McpConfig { + command: string; + args: string[]; + env: Record; +} + +export interface McpTool { + name: string; + description: string; + inputSchema: McpInputSchema; +} + +export interface McpInputSchema { + type: string; + title: string; + properties: { [k: string]: any }; // eslint-disable-line + required: string[]; +} // Retrieve, and transform, tools from the MCP server, into `tools` object we // can send to the LLM. // -export async function getMCPTools(session: ISession): Promise { - return (await invoke('get_mcp_tools', { sessionId: session.id })).map(tool => { +export async function getMCPTools(sessionId: number): Promise { + return (await invoke('get_mcp_tools', { sessionId })).map(tool => { return { type: 'function', function: { diff --git a/src/lib/models/app.svelte.ts b/src/lib/models/app.svelte.ts new file mode 100644 index 0000000..403985f --- /dev/null +++ b/src/lib/models/app.svelte.ts @@ -0,0 +1,131 @@ +import moment from 'moment'; + +import { McpServer, type ToSqlRow } from '$lib/models'; +import Base from '$lib/models/base.svelte'; + +interface Row { + id: number; + name: string; + description: string; + readme: string; + image: string; + interface: string; + nodes: string; + created: string; + modified: string; +} + +export interface Node { + uuid: string; + type: NodeType; + config: { [key: string]: any }; // eslint-disable-line +} + +export enum NodeType { + Context = 'Context', +} + +export enum Interface { + Voice = 'Voice', + Chat = 'Chat', + Dashboard = 'Dashboard', + Daemon = 'Daemon', +} + +export default class App extends Base('apps') { + id?: number = $state(); + name: string = $state('Unknown'); + description: string = $state(''); + readme: string = $state(''); + image: string = $state(''); + interface: Interface = $state(Interface.Chat); + nodes: Node[] = $state([]); + mcpServers: McpServer[] = $state([]); + created?: moment.Moment = $state(); + modified?: moment.Moment = $state(); + + get defaults() { + return { + name: 'Unknown', + description: '', + readme: '', + image: '', + interface: Interface.Chat, + nodes: [], + mcpServers: [], + }; + } + + get context() { + return this.nodes + .filter(n => n.type == NodeType.Context) + .map(n => n.config.value) + .join('\n\n'); + } + + hasContext(): boolean { + return this.nodes.find(n => n.type == NodeType.Context) !== undefined; + } + + addNode(node: Node) { + this.nodes.push(node); + } + + removeNode(node: Node) { + this.nodes = this.nodes.filter(n => n.uuid !== node.uuid); + } + + async addMcpServer(server: McpServer) { + const result = await ( + await this.db() + ).execute(`INSERT INTO apps_mcp_servers (app_id, mcp_server_id) VALUES ($1, $2)`, [ + this.id, + server.id, + ]); + + if (result.rowsAffected == 1) { + this.mcpServers.push(server); + return; + } + + throw 'AddMcpServerError'; + } + + async removeMcpServer(server: McpServer) { + const result = await ( + await this.db() + ).execute('DELETE FROM apps_mcp_servers WHERE app_id = $1 AND mcp_server_id = $2', [ + this.id, + server.id, + ]); + + if (result.rowsAffected == 1) { + this.mcpServers = this.mcpServers.filter(m => m.id == server.id); + return; + } + + throw 'RemoveMcpServerError'; + } + + protected static async fromSql(row: Row): Promise { + return App.new({ + ...row, + interface: Interface[row.interface as keyof typeof Interface], + nodes: JSON.parse(row.nodes), + mcpServers: await McpServer.forApp(row.id), + created: moment.utc(row.created), + modified: moment.utc(row.modified), + }); + } + + protected async toSql(): Promise> { + return { + name: this.name, + description: this.description, + readme: this.readme, + image: this.image, + interface: this.interface, + nodes: JSON.stringify(this.nodes), + }; + } +} diff --git a/src/lib/models/app.ts b/src/lib/models/app.ts deleted file mode 100644 index fb52458..0000000 --- a/src/lib/models/app.ts +++ /dev/null @@ -1,133 +0,0 @@ -import moment from 'moment'; - -import Model, { type ToSqlRow } from '$lib/models/base.svelte'; -import McpServer, { type IMcpServer } from '$lib/models/mcp-server'; - -export interface IApp { - id?: number; - name: string; - description: string; - readme: string; - image: string; - interface: Interface; - nodes: Node[]; - mcpServers: IMcpServer[]; - created?: moment.Moment; - modified?: moment.Moment; -} - -interface Row { - id: number; - name: string; - description: string; - readme: string; - image: string; - interface: string; - nodes: string; - created: string; - modified: string; -} - -export interface Node { - uuid: string; - type: NodeType; - config: { [key: string]: any }; // eslint-disable-line -} - -export enum NodeType { - Context = 'Context', -} - -export enum Interface { - Voice = 'Voice', - Chat = 'Chat', - Dashboard = 'Dashboard', - Daemon = 'Daemon', -} - -export default class App extends Model('apps') { - static defaults = { - name: 'Unknown', - description: '', - readme: '', - image: '', - interface: Interface.Chat, - nodes: [], - mcpServers: [], - }; - - static hasContext(app: IApp): boolean { - return app.nodes?.find(n => n.type == NodeType.Context) !== undefined; - } - - static context(app: IApp): string { - return app.nodes - .filter(n => n.type == NodeType.Context) - .map(n => n.config.value) - .join('\n\n'); - } - - static addNode(app: IApp, node: Node): IApp { - app.nodes.push(node); - return app; - } - - static removeNode(app: IApp, node: Node): IApp { - app.nodes = app.nodes.filter(n => n.uuid !== node.uuid); - return app; - } - - static async addMcpServer(app: IApp, mcpServer: IMcpServer): Promise { - const result = await ( - await this.db() - ).execute(`INSERT INTO apps_mcp_servers (app_id, mcp_server_id) VALUES ($1, $2)`, [ - app.id, - mcpServer.id, - ]); - - if (result.rowsAffected == 1) { - app.mcpServers.push(mcpServer); - return app.mcpServers; - } - - throw 'AddMcpServerError'; - } - - static async removeMcpServer(app: IApp, mcpServer: IMcpServer): Promise { - const result = await ( - await this.db() - ).execute('DELETE FROM apps_mcp_servers WHERE app_id = $1 AND mcp_server_id = $2', [ - app.id, - mcpServer.id, - ]); - - if (result.rowsAffected == 1) { - app.mcpServers = app.mcpServers.filter(m => m.id == mcpServer.id); - return app.mcpServers; - } - - throw 'RemoveMcpServerError'; - } - - protected static async fromSql(row: Row): Promise { - return { - ...row, - interface: Interface[row.interface as keyof typeof Interface], - nodes: JSON.parse(row.nodes), - mcpServers: await McpServer.forApp(row.id), - created: moment.utc(row.created), - modified: moment.utc(row.modified), - }; - } - - protected static async toSql(app: IApp): Promise> { - return { - name: app.name, - description: app.description, - readme: app.readme, - image: app.image, - interface: app.interface, - nodes: JSON.stringify(app.nodes), - }; - } -} diff --git a/src/lib/models/bare.svelte.ts b/src/lib/models/bare.svelte.ts new file mode 100644 index 0000000..813772b --- /dev/null +++ b/src/lib/models/bare.svelte.ts @@ -0,0 +1,40 @@ +/** + * Model class NOT backed by a database + */ +export default function BareModel() { + let repo: T[] = $state([]); + + return class BareModel { + static reset(instances: T[] = []) { + repo = instances; + } + + static add(instance: T) { + repo.push(instance); + } + + static delete(instance: T) { + repo = repo.filter(i => i !== instance); + } + + static all(): T[] { + return repo; + } + + static find(id: string): T | undefined { + return repo.findBy('id', id); + } + + static findBy(params: Partial): T | undefined { + return repo.find(r => Object.entries(params).every(([key, value]) => r[key] == value)); + } + + static first(): T { + return repo[0]; + } + + static last(): T { + return repo[repo.length - 1]; + } + }; +} diff --git a/src/lib/models/base.svelte.ts b/src/lib/models/base.svelte.ts index 308fd87..bcd59bc 100644 --- a/src/lib/models/base.svelte.ts +++ b/src/lib/models/base.svelte.ts @@ -9,104 +9,99 @@ import { info } from '$lib/logger'; export let db: Database; /** - * SQL rows should never include reserved columns. + * Connect to the database */ -export type ToSqlRow = Omit; +async function connect() { + db ||= await Database.load(DATABASE_URL); +} /** - * Columns that should never be included in an UPDATE or INSERT query. + * SQL rows should never include reserved columns. */ -export const ReservedColumns = ['id', 'created', 'modified']; +export type ToSqlRow = Omit; /** * # Model * * Base of all database models. This class supplies the ORM functions required - * to interact with the database and the "repo" pass-through layer. + * to interact with the database and the "repo" cache. * * Model functionality is split by reads and writes. All reads are done from - * the repo, while writes are done directory to the database, then synced - * to the repo. - * - * ## Static Methods All the Way Down - * - * Since Svelte's reactivity only works on basic data structures – more or - * less – we can't pass around instances of models. Instead, functions that - * require an "instance" of a model needs to accept a object as an argument. - * - * NOTE: I'm a bit unsure of a ststic class interface is the right choice for - * models. An alternative would be to build plain JS objects. This would remove - * the need for all the `static` non-sense. :shrug: - * - * ## `Instance` Interface - * - * The `Instance` interface represent the object you pass around the app. They - * are simple JS objects. - * - * `Instance` properties shouild reflect the interface you want to use in the - * app. Meaning, camelCase keys and complex types (if needed). - * - * Foreign key properties should be optional to allow new `Instance`s to be - * created where you don't know the associations at instantiation. + * the cache, while writes are done directory to the database, then synced + * to the cache. * * ## `Row` Interface * * The `Row` interface represents a database row. Meaning, property types * should match database types as closely as possible. * - * For example, if you have a datetime column, it would be returned from the - * database as a string, so declare that property with `string`, `JSON` columns - * are represented as a `string`, etc. + * For example, if you have a `datetime` column, it would be returned from the + * database as a string, so declare that property with `string`. Similarly, `JSON` + * columns would be represented as a `string`, etc. + * + * ## Instantiation + * + * You MUST instantiate a model using the static `new` function. This is to + * work around limitations of JS's constructors + Svelte. + * + * ```ts + * Message.new({ content: 'Heyo' }); + * ``` + * + * ## Reactivity + * + * Models properties MUST be declares using Svelte's `$state()` and provide a + * default value for required properties. * * ## Serielization / Deserialization * - * [De]Serialization is handled through two functions `fromSql` and `toSql` - * that you need to implement on your model. + * [De]Serialization is handled through two functions `static fromSql(row: Row)` and + * `toSql()` that you need to implement on your model. * - * ### `fromSql` + * ### `static fromSql(row: Row)` * - * Converts a database row (`Row`) into an instance (`Instance`). This is where + * Converts a database row (`Row`) into an instance. This is where * you should convert fields like dates from a `string` to a `DateTime`, JSON * columns from a `string` to a "real" object, etc. * * This is called when objects are retrieved from the database. * - * ### `toSql` + * ### `toSql()` * - * Convert an instance (`Instance`) to a database row (`Row`). This is where - * you should convert your complex types into simple database types. For - * example, an object into the JSON stringify'ed version of itself. + * Convert an instance to a database row (`Row`). This is where you should convert + * your complex types into simple database types. For example, an object into the + * JSON stringify'ed version of itself. * * `toSql` should EXCLUDE properties for columns that are set automatically by * the database, like `id`, `created`, or `modified`. * * ## Lifecycle Callbacks * - * You may implement `beforeCreate`, `afterCreate`, `beforeUpdate`, and - * `afterUpdate`. See the documentation for these functions for more specific - * information. + * You may implement `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, + * `beforeSave`, and/or `afterSave`. See the documentation for these functions for + * more specific information. * * ## Usage * - * `Model` is a function. It takes two generic types and the name of the table - * records reside within. + * `Model` is a function. It takes one generic type representing the `Row` and the name + * of the table records reside within. * * @example * * ```ts - * export interface IMessage { - * userId: string; - * content: string; - * } + * import Base from '$lib/models/base.svelte'; * * interface Row { * user_id: string; * content: string; * } * - * class Message extends Model('messages') { + * class Message extends Base('messages') { + * userId: string = $state(''); + * content: string = $state(''); + * * static function fromSql(row: Row): Promise { - * return { + * return new Message({ * id: row.id, * userId: row.user_id, * content: row.content, @@ -115,97 +110,141 @@ export const ReservedColumns = ['id', 'created', 'modified']; * } * } * - * static function toSql(message: IMessage): Promise> { + * function toSql(): Promise> { * return { - * user_id: message.rowId, - * content: message.content, + * user_id: this.rowId, + * content: this.content, * } * } * } * ``` */ -export default function Model(table: string) { - let repo: Interface[] = $state([]); +export default function Model(table: string) { + class ModelClass { + id?: number; - return class Model { - static defaults = {}; - - /** - * Reload records from the database and populate the Repository. - */ - static async sync(): Promise { - repo = []; - - (await this.query(`SELECT * FROM ${table}`)).forEach(record => this.syncOne(record)); - - info(`[green]✔ synced ${table}`); + constructor(params: Partial, privateInvocation = false) { + if (!privateInvocation) { + throw 'InvocationError: must instantiate models using `.new()`'; + } + Object.assign(this, params); } - /** - * Create an empty, default, object. - * - * Use this instead of the `new Whatever()` syntax, as we need to - * always be passing around plain old JS objects for Svelte's - * reactivity to work properly. - */ - static default(defaults: Partial = {}): Interface { - defaults = - typeof this.defaults == 'function' - ? { ...this.defaults(), ...defaults } - : { ...this.defaults, ...defaults }; + static new( + this: T, + params: Partial> = {} + ): InstanceType { + const inst = new this({}, true); + Object.assign(inst, inst.default); + Object.assign(inst, params); + return inst as InstanceType; + } - return defaults as Interface; + static async create( + this: T, + params: Partial> = {} + ): Promise> { + return await this.new(params).save(); } /** * Does a record with specific params exist. */ - static exists(params: Partial): boolean { + static exists( + this: T, + params: Partial> + ): boolean { return this.where(params).length > 0; } /** * Retrieve all records. */ - static all(): Interface[] { - return repo; + static all(this: T): InstanceType[] { + return repo as InstanceType[]; } /** * Find an individual record by`id`. */ - static find(id: number | string): Interface { - return this.all().find(m => m.id == Number(id)) as Interface; + static find(this: T, id: number): InstanceType { + return this.all().find(m => m.id == Number(id)) as InstanceType; } /** * Find the first occurence by a subset of the model's properties. */ - static findBy(params: Partial): Interface | undefined { + static findBy( + this: T, + params: Partial> + ): InstanceType | undefined { return this.where(params)[0]; } + /** + * Find by specific properties or instantiate a new instance with them. + */ + static findByOrNew( + this: T, + params: Partial> + ): InstanceType { + return this.findBy(params) || this.new(params); + } + /** * Find a collection of records by a set of the model's properties. */ - static where(params: Partial): Interface[] { - return repo.filter(m => { - return Object.entries(params).every(([key, value]) => m[key] == value); - }); + static where( + this: T, + params: Partial> + ): InstanceType[] { + return repo.filter(m => + Object.entries(params).every(([k, v]) => (m as Obj)[k] == v) + ) as InstanceType[]; } /** * Find the first record */ - static first(): Interface { - return repo[0]; + static first(this: T): InstanceType { + return repo[0] as InstanceType; } /** * Find the last record */ - static last(): Interface { - return repo[repo.length - 1]; + static last(this: T): InstanceType { + return repo[repo.length - 1] as InstanceType; + } + + static async deleteBy( + this: T, + params: Partial> + ): Promise { + return (await Promise.all(this.where(params).map(async m => await m.delete()))).every( + i => i == true + ); + } + + /** + * Default values for new instance + */ + get default() { + return {}; + } + + /** + * Delete a record, by`id`. + */ + async delete(): Promise { + const query = await db.execute(`DELETE FROM ${table} WHERE id = $1`, [this.id]); + + if (query.rowsAffected == 1) { + repo = repo.filter(m => m.id !== this.id); + return true; + } else { + return false; + } } /** @@ -213,12 +252,54 @@ export default function Model(table: str * * If `params` contains `id`, it will update, otherwise create. */ - static async save(params: Interface): Promise { - if (params.id) { - return await this.update(params); - } else { - return await this.create(params); - } + async save(): Promise { + return this.id ? await this.update() : await this.create(); + } + + /** + * Database connection + */ + protected async db() { + await connect(); + return db; + } + + /** + * Update a record. + * + * Only pass the columns you intend to change. + */ + private async update(): Promise { + const cls = this.constructor as typeof ModelClass; + + let row = await this.toSql(); + row = await this.beforeSave(row); + row = await this.beforeUpdate(row); + + const query = new Query(row); + + const instance = ( + await cls.query( + ` + UPDATE + ${table} + SET + ${query.setters} + WHERE + id = ${this.id} + RETURNING + * + `, + query.values + ) + )[0]; + + cls.syncOne(instance); + + await instance.afterUpdate(); + await instance.afterSave(); + + return instance; } /** @@ -230,289 +311,155 @@ export default function Model(table: str * `id`, `created`, and `modified` values are ALWAYS ignored, since * they are garaunteed to be automatically set by the database. */ - static async create(_params: Partial): Promise { - let row = await this.toSql( - this.exclude( - { - ...this.default(), - ..._params, - }, - ReservedColumns - ) - ); + private async create(): Promise { + const cls = this.constructor as typeof ModelClass; + let row = await this.toSql(); row = await this.beforeSave(row); row = await this.beforeCreate(row); - const columns = this.columns(row).join(', '); - const binds = this.binds(row).join(', '); - const values = Object.values(row); + const query = new Query(row); - let instance = ( - await this.query( - `INSERT INTO ${table} (${columns}) VALUES(${binds}) RETURNING * `, - values + const instance = ( + await cls.query( + ` + INSERT INTO + ${table} (${query.columns}) + VALUES + (${query.binds}) + RETURNING + * + `, + query.values ) )[0]; - this.syncOne(instance); - this.removeEphemeralInstances(); + cls.syncOne(instance); - instance = await this.afterCreate(instance); - instance = await this.afterSave(instance); + await instance.afterCreate(); + await instance.afterSave(); return instance; } /** - * Update a record. - * - * Only pass the columns you intend to change. + * Reload records from the database and populate the Repository. */ - static async update(_params: Interface): Promise { - let row = await this.toSql(this.exclude(_params, ReservedColumns)); - - row = await this.beforeSave(row); - row = await this.beforeUpdate(row); - - const setters = this.setters(row).join(', '); - const values = [...Object.values(row), _params.id]; - const idBind = `$${values.length} `; - - let instance = ( - await this.query( - `UPDATE ${table} SET ${setters} WHERE id = ${idBind} RETURNING * `, - values - ) - )[0]; - - this.syncOne(instance); - this.removeEphemeralInstances(); - - instance = await this.afterUpdate(instance); - instance = await this.afterSave(instance); - - return instance; - } - - /** - * Delete a record, by`id`. - */ - static async delete(id: number): Promise { - const result = - (await (await this.db()).execute(`DELETE FROM ${table} WHERE id = $1`, [id])) - .rowsAffected == 1; - - const i = this.find(id); - this.syncRemove(i); - - return result; - } - - /** - * Delete a record by a subset of columns - */ - static async deleteBy(params: Partial): Promise { - const conditions = this.setters(params).join(' AND '); - const values = Object.values(params); - - const instances = await this.query( - `SELECT * FROM ${table} WHERE ${conditions} `, - values - ); - - const success = - ( - await ( - await this.db() - ).execute(`DELETE FROM ${table} WHERE ${conditions} `, values) - ).rowsAffected >= 1; - - if (success) { - instances.forEach(instance => { - this.syncRemove(instance); - }); - } - - return success; - } - - /** - * Run a query in the database, returning an object implementing`Instance`. - */ - protected static async query(sql: string, values: unknown[] = []): Promise { - const result: Row[] = await (await this.db()).select(sql, values); - - return await Promise.all(result.map(async row => await this.fromSql(row))); - } - - /** - * Memoized database connection. - */ - protected static async db(): Promise { - if (!db) { - db = await Database.load(DATABASE_URL); - } - return db; + static async sync() { + repo = await this.query(`SELECT * FROM ${table}`); + info(`[green]✔ ${table} synced`); } /** * Update, or Add, a single record */ - private static syncOne(record: Interface) { - const existing = this.find(record.id); + protected static syncOne(this: T, instance: InstanceType) { + const existing = this.find(Number(instance.id)); if (existing) { - Object.assign(existing, record); + Object.assign(existing, instance); } else { - const reactive = $state(record); - repo.push(reactive); + repo.push(instance); } } /** - * Remove an instance from the repo + * Execute a query in the database. */ - private static syncRemove(instance: Interface) { - repo = repo.filter(i => i.id !== instance.id); + protected static async query(sql: string, values: unknown[] = []): Promise { + await connect(); + const rows = await db.select(sql, values); + const promises = rows.map(async row => await this.fromSql(row)); + return (await Promise.all(promises)) as T[]; } - /** - * Remove ephemeral instances from the repo. - * - * Pages will often push an "empty" instance into a list of models, to - * so that it renders in a list and the user can configure it. - * - * We need to remove those "ephemeral" instances when we save a record, - * otherwise both would show up and appear to be duplicate. - * - * This leaves only persisted records(ones with an`id`). - */ - private static removeEphemeralInstances() { - repo = repo.filter(record => record.id !== undefined); - } + // Abstract Functions /** - * Exclude k / v pairs in an object, by a list of keys. - */ - private static exclude(params: T, exclude: string[]): T { - return Object.fromEntries( - Object.entries(params).filter(([k, _]) => !exclude.includes(k)) - ) as T; - } - - /** - * Retrieve the list of columns from`params`. - * - * Mostly just a more descriptive name for the operation. - */ - private static columns

(params: P): string[] { - return Object.keys(params); - } - - /** - * Generate a list of `$k = $#` statements from`params`. - * - * `$k` is the name of the column and `$#` is the bind parameter. - */ - private static setters

(params: P): string[] { - return Object.keys(params).map((k, i) => `${k} = $${i + 1} `); - } - - /** - * Individual numeric bind statements, like`['$1', '$2']`. - */ - private static binds

(params: P): string[] { - return Object.values(params).map((_, i) => `$${i + 1} `); - } - - /** - * Transform a raw database row into an `Interface` object. + * Transform a database `Row` into an instance. */ // eslint-disable-next-line - protected static async fromSql(row: Row): Promise { - throw 'NotImplementedError'; + protected static async fromSql(row: R): Promise { + throw 'NotImeplementedError'; } /** - * Transform an `Interface` object into a `Row` of database compatiable - * values. + * Transform an instance into a database `Row` */ - // eslint-disable-next-line - protected static async toSql(instance: ToSqlRow): Promise> { - throw 'NotImplementedError'; + protected async toSql(): Promise> { + throw 'NotImeplementedError'; } /** - * Transform the `Row` object before it's used to generate a query. + * ## Callbacks + * + * Model callbacks occur when an instance is saved (created or updates) + * or one is read from the database. + * + * ### Callback Order + * - beforeSave + * - before[Create|Update] + * - after[Create|Update] + * - afterSave */ - protected static async beforeSave(row: ToSqlRow): Promise> { + + protected async beforeSave(row: ToSqlRow): Promise> { return row; } - /** - * Transform the `Instance` object after it's created/updated/retrieved - * from the database. - */ - protected static async afterSave(instance: Interface): Promise { - return instance; - } - - protected static async beforeCreate(row: ToSqlRow): Promise> { + protected async beforeCreate(row: ToSqlRow): Promise> { return row; } - protected static async afterCreate(instance: Interface): Promise { - return instance; - } - - protected static async beforeUpdate(row: ToSqlRow): Promise> { + protected async beforeUpdate(row: ToSqlRow): Promise> { return row; } - protected static async afterUpdate(instance: Interface): Promise { - return instance; + protected async afterCreate(): Promise { + // noop } - }; + + protected async afterUpdate(): Promise { + // noop + } + + protected async afterSave(): Promise { + // noop + } + } + + /** + * Cache of model instances + */ + let repo: InstanceType[] = $state([]); + + return ModelClass; } -/** - * Model class NOT backed by a database - */ -export function BareModel() { - let repo: T[] = $state([]); +class Query { + row: R; - return class BareModel { - static reset(instances: T[] = []) { - repo = instances; - } + constructor(row: R) { + this.row = row; + } - static add(instance: T) { - repo.push(instance); - } + get columns() { + return Object.keys(this.row).join(', '); + } - static delete(instance: T) { - repo = repo.filter(i => i !== instance); - } + get setters() { + return Object.keys(this.row) + .map((k, i) => `${k} = $${i + 1}`) + .join(', '); + } - static all(): T[] { - return repo; - } + get binds() { + return Object.values(this.row) + .map((_, i) => `$${i + 1}`) + .join(', '); + } - static find(id: string): T | undefined { - return repo.findBy('id', id); - } - - static findBy(params: Partial): T | undefined { - return repo.find(r => Object.entries(params).every(([key, value]) => r[key] == value)); - } - - static first(): T { - return repo[0]; - } - - static last(): T { - return repo[repo.length - 1]; - } - }; + get values() { + return Object.values(this.row); + } } diff --git a/src/lib/models/config.svelte.ts b/src/lib/models/config.svelte.ts new file mode 100644 index 0000000..7961284 --- /dev/null +++ b/src/lib/models/config.svelte.ts @@ -0,0 +1,89 @@ +import { BaseDirectory, exists, readTextFile } from '@tauri-apps/plugin-fs'; + +import { type ToSqlRow } from '$lib/models'; +import Base from '$lib/models/base.svelte'; + +interface Row { + id: number; + key: string; + value: string; +} + +export type ConfigKey = + | 'latest-session-id' + | 'welcome-agreed' + | 'skipped-version' + | 'default-model' + | 'null'; + +export default class Config extends Base('config') { + id?: number = $state(); + key: ConfigKey = $state('null'); + value: unknown = $state(); + + @getset('latest-session-id') + static latestSessionId: number; + + @getset('welcome-agreed') + static agreedToWelcome: boolean; + + @getset('skipped-version') + static skippedVersions: string[]; + + @getset('default-model') + static defaultModel: string; + + static async migrate() { + const filename = 'tome.conf.json'; + const opt = { baseDir: BaseDirectory.AppData }; + + if (!(await exists(filename, opt))) { + return; + } + + try { + Object.entries(JSON.parse(await readTextFile(filename, opt))).forEach(([k, value]) => { + const key = k as ConfigKey; + if (!this.exists({ key })) { + this.create({ key, value }); + } + }); + } catch { + return; + } + } + + protected static async fromSql(row: Row): Promise { + return Config.new({ + id: row.id, + key: row.key as ConfigKey, + value: JSON.parse(row.value), + }); + } + + protected async toSql(): Promise> { + return { + key: this.key, + value: JSON.stringify(this.value), + }; + } +} + +function getset(key: ConfigKey) { + return function (target: object, property: string) { + function get() { + return Config.findBy({ key })?.value; + } + + async function set(value: unknown) { + const config = Config.findBy({ key }) || Config.new({ key }); + config.value = value; + await config.save(); + } + + Object.defineProperty(target, property, { + get, + set, + }); + }; +} diff --git a/src/lib/models/config.ts b/src/lib/models/config.ts deleted file mode 100644 index e80bba2..0000000 --- a/src/lib/models/config.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { BaseDirectory, exists, readTextFile } from '@tauri-apps/plugin-fs'; - -import Model, { type ToSqlRow } from '$lib/models/base.svelte'; - -export interface IConfig { - id?: number; - key: string; - value: unknown; -} - -interface Row { - id: number; - key: string; - value: string; -} - -type ConfigKey = 'latest-session-id' | 'welcome-agreed' | 'skipped-version' | 'default-model'; - -export default class Config extends Model('config') { - @getset('latest-session-id') - static latestSessionId: number; - - @getset('welcome-agreed') - static agreedToWelcome: boolean; - - @getset('skipped-version') - static skippedVersions: string[]; - - @getset('default-model') - static defaultModel: string; - - static get(key: string) { - return this.findBy({ key })?.value; - } - - static async set(key: ConfigKey, value: unknown) { - const config = this.findBy({ key }) || this.default({ key }); - config.value = value; - await this.save(config); - } - - static async migrate() { - const filename = 'tome.conf.json'; - const opt = { baseDir: BaseDirectory.AppData }; - - if (!(await exists(filename, opt))) { - return; - } - - try { - Object.entries(JSON.parse(await readTextFile(filename, opt))).forEach( - ([key, value]) => { - if (!this.exists({ key })) { - this.create({ key, value }); - } - } - ); - } catch { - return; - } - } - - protected static async fromSql(row: Row): Promise { - return { - id: row.id, - key: row.key, - value: JSON.parse(row.value), - }; - } - - protected static async toSql(config: IConfig): Promise> { - return { - key: config.key, - value: JSON.stringify(config.value), - }; - } -} - -function getset(key: ConfigKey) { - return function (target: object, property: string) { - function get() { - return Config.get(key); - } - - function set(value: unknown) { - Config.set(key, value); - } - - Object.defineProperty(target, property, { - get, - set, - }); - }; -} diff --git a/src/lib/models/engine.svelte.ts b/src/lib/models/engine.svelte.ts new file mode 100644 index 0000000..da4c947 --- /dev/null +++ b/src/lib/models/engine.svelte.ts @@ -0,0 +1,109 @@ +import type { ToSqlRow } from './base.svelte'; + +import Gemini from '$lib/engines/gemini/client'; +import Ollama from '$lib/engines/ollama/client'; +import OpenAI from '$lib/engines/openai/client'; +import type { Client, ClientOptions } from '$lib/engines/types'; +import { error } from '$lib/logger'; +import { type IModel, Model } from '$lib/models'; +import Base from '$lib/models/base.svelte'; + +const AVAILABLE_MODELS: Record = { + 'openai-compat': 'all', + ollama: 'all', + openai: ['gpt-4o', 'o4-mini', 'gpt-4.5-preview', 'gpt-4.1', 'gpt-4.1-mini'], + gemini: [ + 'gemini-2.5-pro-exp-03-25', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + 'gemini-2.5-flash-preview-05-20', + 'gemini-2.5-pro-preview-05-06', + 'gemini-1.5-pro', + ], +}; + +type EngineType = 'ollama' | 'openai' | 'gemini' | 'openai-compat'; + +interface Row { + id: number; + name: string; + type: string; + options: string; +} + +export default class Engine extends Base('engines') { + id?: number = $state(); + name: string = $state(''); + type: EngineType = $state('openai-compat'); + options: ClientOptions = $state({ url: '', apiKey: '' }); + models: IModel[] = $state([]); + + static async sync() { + await super.sync(); + await Model.sync(); + } + + static fromModelId(id: string): Engine | undefined { + return this.findBy({ type: id.split(':')[0] as EngineType }); + } + + get client(): Client | undefined { + const Client = { + ollama: Ollama, + openai: OpenAI, + gemini: Gemini, + 'openai-compat': OpenAI, + }[this.type]; + + if (Client) { + try { + return new Client({ ...this.options, engineId: Number(this.id) }); + } catch { + return undefined; + } + } + } + + protected async afterUpdate() { + await Model.sync(); + } + + protected static async fromSql(row: Row): Promise { + const engine = Engine.new({ + id: row.id, + name: row.name, + type: row.type as EngineType, + options: JSON.parse(row.options), + models: [], + }); + + if (engine.client) { + try { + engine.models = (await engine.client.models()) + .filter( + m => + AVAILABLE_MODELS[engine.type] == 'all' || + AVAILABLE_MODELS[engine.type].includes(m.name) + ) + .map(m => ({ + ...m, + id: m.name, + engineId: Number(engine.id), + })) + .sortBy('name'); + } catch { + // noop + } + } + + return engine; + } + + protected async toSql(): Promise> { + return { + name: this.name, + type: this.type, + options: JSON.stringify(this.options), + }; + } +} diff --git a/src/lib/models/engine.ts b/src/lib/models/engine.ts deleted file mode 100644 index c94f9c1..0000000 --- a/src/lib/models/engine.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { IModel } from './model'; - -import Gemini from '$lib/engines/gemini/client'; -import Ollama from '$lib/engines/ollama/client'; -import OpenAI from '$lib/engines/openai/client'; -import type { Client, ClientOptions } from '$lib/engines/types'; -import Base, { type ToSqlRow } from '$lib/models/base.svelte'; -import Model from '$lib/models/model'; - -const AVAILABLE_MODELS: Record = { - 'openai-compat': 'all', - ollama: 'all', - openai: ['gpt-4o', 'o4-mini', 'gpt-4.5-preview', 'gpt-4.1', 'gpt-4.1-mini'], - gemini: [ - 'gemini-2.5-pro-exp-03-25', - 'gemini-2.0-flash', - 'gemini-2.0-flash-lite', - 'gemini-2.5-flash-preview-05-20', - 'gemini-2.5-pro-preview-05-06', - 'gemini-1.5-pro', - ], -}; - -export interface IEngine { - id: number; - name: string; - type: 'ollama' | 'openai' | 'gemini' | 'openai-compat'; - options: ClientOptions; - models: IModel[]; - client?: Client; -} - -interface Row { - id: number; - name: string; - type: string; - options: string; -} - -export default class Engine extends Base('engines') { - static defaults = { - name: '', - type: 'openai-compat', - options: { - url: '', - apiKey: '', - }, - }; - - static fromModelId(id: string): IEngine | undefined { - return this.findBy({ type: id.split(':')[0] as IEngine['type'] }); - } - - static client(engine: IEngine): Client | undefined { - const Client = { - ollama: Ollama, - openai: OpenAI, - gemini: Gemini, - 'openai-compat': OpenAI, - }[engine.type]; - - if (Client) { - return new Client({ ...engine.options, engine }); - } - } - - protected static async afterUpdate(engine: IEngine): Promise { - await Model.sync(); - return engine; - } - - protected static async fromSql(row: Row): Promise { - const engine: IEngine = { - id: row.id, - name: row.name, - type: row.type as IEngine['type'], - options: JSON.parse(row.options), - models: [], - }; - - let client: Client | undefined; - let models: IModel[] = []; - - try { - client = this.client(engine) as Client; - models = (await client.models()) - .filter( - m => - AVAILABLE_MODELS[engine.type] == 'all' || - AVAILABLE_MODELS[engine.type].includes(m.name) - ) - .map(m => ({ - ...m, - id: m.name, - engineId: engine.id, - })) - .sortBy('name'); - } catch { - // noop - } - - return { - ...engine, - client, - models, - }; - } - - protected static async toSql(engine: ToSqlRow): Promise> { - return { - name: engine.name, - type: engine.type, - options: JSON.stringify(engine.options), - }; - } -} diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts index 0ad2fcd..d75b30b 100644 --- a/src/lib/models/index.ts +++ b/src/lib/models/index.ts @@ -1,25 +1,30 @@ -import { info } from '$lib/logger'; -import App from '$lib/models/app'; -import Engine from '$lib/models/engine'; -import McpServer from '$lib/models/mcp-server'; -import Message from '$lib/models/message'; -import Session from '$lib/models/session'; -import Setting from '$lib/models/setting'; +import App from '$lib/models/app.svelte'; +import Config from '$lib/models/config.svelte'; +import Engine from '$lib/models/engine.svelte'; +import McpServer from '$lib/models/mcp-server.svelte'; +import Message from '$lib/models/message.svelte'; +import Model from '$lib/models/model'; +import Session from '$lib/models/session.svelte'; +import Setting from '$lib/models/setting.svelte'; -export { default as App, type IApp } from '$lib/models/app'; -export { default as Engine, type IEngine } from '$lib/models/engine'; -export { type IMcpServer, default as McpServer } from '$lib/models/mcp-server'; -export { type IMessage, default as Message } from '$lib/models/message'; +export { default as App } from '$lib/models/app.svelte'; +export { default as BareModel } from '$lib/models/bare.svelte'; +export { default as Base, type ToSqlRow } from '$lib/models/base.svelte'; +export { default as Config } from '$lib/models/config.svelte'; +export { default as Engine } from '$lib/models/engine.svelte'; +export { default as McpServer } from '$lib/models/mcp-server.svelte'; +export { default as Message } from '$lib/models/message.svelte'; export { type IModel, default as Model } from '$lib/models/model'; -export { type ISession, default as Session } from '$lib/models/session'; -export { type ISetting, default as Setting } from '$lib/models/setting'; +export { default as Session } from '$lib/models/session.svelte'; +export { default as Setting } from '$lib/models/setting.svelte'; export async function resync() { + await Engine.sync(); + await Model.sync(); await App.sync(); await Session.sync(); await Message.sync(); await McpServer.sync(); await Setting.sync(); - await Engine.sync(); - info('[green]✔ resynced'); + await Config.sync(); } diff --git a/src/lib/models/mcp-server.svelte.ts b/src/lib/models/mcp-server.svelte.ts new file mode 100644 index 0000000..9804594 --- /dev/null +++ b/src/lib/models/mcp-server.svelte.ts @@ -0,0 +1,142 @@ +import { invoke } from '@tauri-apps/api/core'; + +import { Session, type ToSqlRow } from '$lib/models'; +import Base from '$lib/models/base.svelte'; + +interface Row { + id: number; + name: string; + command: string; + metadata: string; + args: string; + env: string; +} + +interface Metadata { + protocolVersion: string; + capabilities: { + tools: Record; // eslint-disable-line + }; + serverInfo: { + name?: string; + version: string; + }; +} + +export default class McpServer extends Base('mcp_servers') { + id?: number = $state(); + name: string = $state('Installing...'); + command: string = $state(''); + metadata?: Metadata = $state({} as Metadata); + args: string[] = $state([]); + env: Record = $state({}); + + get defaults() { + return { + name: 'Installing...', + command: '', + metadata: { + protocolVersion: '', + capabilities: { + tools: {}, + }, + serverInfo: { + name: undefined, + version: '', + }, + }, + args: [], + env: {}, + }; + } + + static async forApp(appId: number): Promise { + return await this.query( + ` + SELECT + * + FROM + mcp_servers + WHERE + id IN (SELECT mcp_server_id FROM apps_mcp_servers WHERE app_id = $1) + `, + [appId] + ); + } + + async start(session: Session) { + await invoke('start_mcp_server', { + sessionId: session.id, + command: this.command, + args: this.args, + env: this.env, + }); + } + + async stop(session: Session) { + await invoke('stop_mcp_server', { + sessionId: session.id, + name: this.name, + }); + } + + async beforeCreate(row: Row): Promise> { + const metadata: Metadata = JSON.parse( + await invoke('get_metadata', { + command: row.command, + args: JSON.parse(row.args), + env: JSON.parse(row.env), + }) + ); + + row.metadata = JSON.stringify(metadata); + row.name = metadata.serverInfo?.name + ?.replace('mcp-server/', '') + ?.replace('/', '-') as string; + + return row; + } + + static async fromSql(row: Row): Promise { + return McpServer.new({ + id: row.id, + name: row.name, + command: row.command, + metadata: JSON.parse(row.metadata), + args: JSON.parse(row.args), + env: JSON.parse(row.env), + }); + } + + async toSql(): Promise> { + return { + name: this.name, + command: this.command, + metadata: JSON.stringify(this.metadata), + args: JSON.stringify(this.args), + env: JSON.stringify(this.env), + }; + } + + async rename(newName: string) { + const oldName = this.name; + this.name = newName; + await this.save(); + + // Update the server name in any active sessions + const sessions = await Session.all(); + for (const session of sessions) { + if (session.hasMcpServer(oldName)) { + await invoke('rename_mcp_server', { + sessionId: session.id, + oldName, + newName + }); + session.config.enabledMcpServers = session.config.enabledMcpServers?.map( + name => name === oldName ? newName : name + ); + await session.save(); + } + } + } +} diff --git a/src/lib/models/mcp-server.ts b/src/lib/models/mcp-server.ts deleted file mode 100644 index 3f21992..0000000 --- a/src/lib/models/mcp-server.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; - -import type { ISession } from './session'; - -import Model, { type ToSqlRow } from '$lib/models/base.svelte'; - -export interface IMcpServer { - id?: number; - name: string; - command: string; - metadata?: Metadata; - args: string[]; - env: Record; -} - -interface Row { - id: number; - name: string; - command: string; - metadata: string; - args: string; - env: string; -} - -interface Metadata { - protocolVersion: string; - capabilities: { - tools: Record; // eslint-disable-line - }; - serverInfo: { - name?: string; - version: string; - }; -} - -export default class McpServer extends Model('mcp_servers') { - static defaults = { - name: 'Installing...', - command: '', - metadata: { - protocolVersion: '', - capabilities: { - tools: {}, - }, - serverInfo: { - name: undefined, - version: '', - }, - }, - args: [], - env: {}, - }; - - static async forApp(appId: number): Promise { - return await this.query( - 'SELECT * FROM mcp_servers WHERE id IN (SELECT mcp_server_id FROM apps_mcp_servers WHERE app_id = $1)', - [appId] - ); - } - - static async start(server: IMcpServer, session: ISession) { - await invoke('start_mcp_server', { - sessionId: session.id, - command: server.command, - args: server.args, - env: server.env, - }); - } - - static async stop(server: IMcpServer, session: ISession) { - await invoke('stop_mcp_server', { - sessionId: session.id, - name: server.name, - }); - } - - static async afterCreate(server: IMcpServer): Promise { - const metadata: Metadata = JSON.parse( - await invoke('get_metadata', { - command: server.command, - args: server.args, - env: server.env, - }) - ); - - const name = metadata.serverInfo?.name - ?.replace('mcp-server/', '') - ?.replace('/', '-') as string; - - return await this.update({ - ...server, - name, - metadata, - }); - } - - static async fromSql(row: Row): Promise { - return { - id: row.id, - name: row.name, - command: row.command, - metadata: JSON.parse(row.metadata), - args: JSON.parse(row.args), - env: JSON.parse(row.env), - }; - } - - static async toSql(server: IMcpServer): Promise> { - return { - name: server.name, - command: server.command, - metadata: JSON.stringify(server.metadata), - args: JSON.stringify(server.args), - env: JSON.stringify(server.env), - }; - } -} diff --git a/src/lib/models/message.svelte.ts b/src/lib/models/message.svelte.ts new file mode 100644 index 0000000..5510a28 --- /dev/null +++ b/src/lib/models/message.svelte.ts @@ -0,0 +1,89 @@ +import moment from 'moment'; + +import type { Role, ToolCall } from '$lib/engines/types'; +import { Session, type ToSqlRow } from '$lib/models'; +import Base from '$lib/models/base.svelte'; + +interface Row { + id: number; + role: string; + content: string; + thought?: string; + model: string; + name: string; + tool_calls: string; + session_id: number; + response_id?: number; + tool_call_id?: string; + created: string; + modified: string; +} + +export default class Message extends Base('messages') { + id?: number = $state(); + role: Role = $state('user'); + content: string = $state(''); + thought?: string = $state(); + model: string = $state(''); + name: string = $state(''); + toolCalls: ToolCall[] = $state([]); + sessionId?: number = $state(); + responseId?: number = $state(); + toolCallId?: string = $state(); + created?: moment.Moment = $state(); + modified?: moment.Moment = $state(); + + get defaults() { + return { + role: 'user', + content: '', + model: '', + name: '', + toolCalls: [], + }; + } + + get response(): Message | undefined { + return Message.findBy({ toolCallId: this.toolCalls[0].id }); + } + + get session(): Session { + return Session.find(this.sessionId as number); + } + + protected async afterCreate() { + const session = Session.find(this.sessionId as number); + await session.summarize(); + } + + protected static async fromSql(row: Row): Promise { + return Message.new({ + id: row.id, + role: row.role as Role, + content: row.content, + thought: row.thought, + model: row.model, + name: row.name, + toolCalls: JSON.parse(row.tool_calls), + sessionId: row.session_id, + responseId: row.response_id, + toolCallId: row.tool_call_id, + created: moment.utc(row.created), + modified: moment.utc(row.modified), + }); + } + + protected async toSql(): Promise> { + return { + role: this.role, + content: this.content, + thought: this.thought, + model: this.model, + name: this.name, + tool_calls: JSON.stringify(this.toolCalls), + session_id: this.sessionId as number, + response_id: this.responseId, + tool_call_id: this.toolCallId, + }; + } +} diff --git a/src/lib/models/message.ts b/src/lib/models/message.ts deleted file mode 100644 index 940752a..0000000 --- a/src/lib/models/message.ts +++ /dev/null @@ -1,90 +0,0 @@ -import moment from 'moment'; - -import type { Role, ToolCall } from '$lib/engines/types'; -import Model, { type ToSqlRow } from '$lib/models/base.svelte'; -import Session, { type ISession } from '$lib/models/session'; - -export interface IMessage { - id?: number; - role: Role; - content: string; - thought?: string; - model: string; - name: string; - toolCalls: ToolCall[]; - sessionId?: number; - responseId?: number; - toolCallId?: string; - created?: moment.Moment; - modified?: moment.Moment; -} - -interface Row { - id: number; - role: string; - content: string; - thought?: string; - model: string; - name: string; - tool_calls: string; - session_id: number; - response_id?: number; - tool_call_id?: string; - created: string; - modified: string; -} - -export default class Message extends Model('messages') { - static defaults = { - role: 'user', - content: '', - model: '', - name: '', - toolCalls: [], - }; - - static response(message: IMessage): IMessage | undefined { - return Message.findBy({ toolCallId: message.toolCalls[0].id }); - } - - static session(message: IMessage): ISession { - return Session.find(message.sessionId as number); - } - - protected static async afterCreate(message: IMessage): Promise { - const session = Session.find(message.sessionId as number); - await Session.summarize(session, session.config.model); - return message; - } - - protected static async fromSql(row: Row): Promise { - return { - id: row.id, - role: row.role as Role, - content: row.content, - thought: row.thought, - model: row.model, - name: row.name, - toolCalls: JSON.parse(row.tool_calls), - sessionId: row.session_id, - responseId: row.response_id, - toolCallId: row.tool_call_id, - created: moment.utc(row.created), - modified: moment.utc(row.modified), - }; - } - - protected static async toSql(message: IMessage): Promise> { - return { - role: message.role, - content: message.content, - thought: message.thought, - model: message.model, - name: message.name, - tool_calls: JSON.stringify(message.toolCalls), - session_id: message.sessionId as number, - response_id: message.responseId, - tool_call_id: message.toolCallId, - }; - } -} diff --git a/src/lib/models/message/ollama.ts b/src/lib/models/message/ollama.ts index c4ae9d6..128db76 100644 --- a/src/lib/models/message/ollama.ts +++ b/src/lib/models/message/ollama.ts @@ -1,12 +1,12 @@ -import type { Message } from 'ollama'; +import type { Message as OllamaMessage } from 'ollama'; -import type { IMessage } from '$lib/models/message'; +import { Message } from '$lib/models'; export default { from, }; -export function from(message: IMessage): Message { +export function from(message: Message): OllamaMessage { return { role: message.role, content: message.content, diff --git a/src/lib/models/message/openai.ts b/src/lib/models/message/openai.ts index de71bdd..2b8f7c9 100644 --- a/src/lib/models/message/openai.ts +++ b/src/lib/models/message/openai.ts @@ -1,12 +1,12 @@ import { OpenAI } from 'openai'; -import type { IMessage } from '$lib/models/message'; +import { Message } from '$lib/models'; export default { from, }; -export function from(message: IMessage): OpenAI.ChatCompletionMessageParam { +export function from(message: Message): OpenAI.ChatCompletionMessageParam { if (message.role == 'assistant') { return fromAssistant(message); } else if (message.role == 'system') { @@ -18,14 +18,14 @@ export function from(message: IMessage): OpenAI.ChatCompletionMessageParam { } } -export function fromUser(message: IMessage): OpenAI.ChatCompletionUserMessageParam { +export function fromUser(message: Message): OpenAI.ChatCompletionUserMessageParam { return { role: 'user', content: message.content, }; } -export function fromAssistant(message: IMessage): OpenAI.ChatCompletionAssistantMessageParam { +export function fromAssistant(message: Message): OpenAI.ChatCompletionAssistantMessageParam { return { role: 'assistant', content: message.content, @@ -33,7 +33,7 @@ export function fromAssistant(message: IMessage): OpenAI.ChatCompletionAssistant }; } -export function fromTool(message: IMessage): OpenAI.ChatCompletionToolMessageParam { +export function fromTool(message: Message): OpenAI.ChatCompletionToolMessageParam { return { tool_call_id: message.toolCallId as string, role: 'tool', @@ -41,14 +41,14 @@ export function fromTool(message: IMessage): OpenAI.ChatCompletionToolMessagePar }; } -export function fromSystem(message: IMessage): OpenAI.ChatCompletionSystemMessageParam { +export function fromSystem(message: Message): OpenAI.ChatCompletionSystemMessageParam { return { role: 'system', content: message.content, }; } -function toolCalls(message: IMessage): OpenAI.ChatCompletionMessageToolCall[] | undefined { +function toolCalls(message: Message): OpenAI.ChatCompletionMessageToolCall[] | undefined { if (message.toolCalls.length == 0) { return; } diff --git a/src/lib/models/model.ts b/src/lib/models/model.ts index 722c068..935278b 100644 --- a/src/lib/models/model.ts +++ b/src/lib/models/model.ts @@ -1,6 +1,5 @@ -import { BareModel } from '$lib/models/base.svelte'; -import Config from '$lib/models/config'; -import Engine from '$lib/models/engine'; +import { Config, Engine } from '$lib/models'; +import BareModel from '$lib/models/bare.svelte'; export interface IModel { id: string; @@ -18,7 +17,7 @@ export default class Model extends BareModel() { } static default(): IModel { - return this.find(Config.defaultModel) || Engine.first().models[0]; + return this.find(Config.defaultModel) || this.first(); } static findByOrDefault(params: Partial): IModel { diff --git a/src/lib/models/session.svelte.ts b/src/lib/models/session.svelte.ts new file mode 100644 index 0000000..82d161c --- /dev/null +++ b/src/lib/models/session.svelte.ts @@ -0,0 +1,189 @@ +import moment from 'moment'; + +import Engine from './engine.svelte'; + +import type { Tool } from '$lib/engines/types'; +import { getMCPTools } from '$lib/mcp'; +import { App, McpServer, Message, Model, Setting } from '$lib/models'; +import Base, { type ToSqlRow } from '$lib/models/base.svelte'; + +/** + * Generic context for the LLM. + */ +export const SYSTEM_PROMPT = + 'You are Tome, created by Runebook, which is an software company located in Oakland, CA. You are a helpful assistant.'; + +/** + * Prompt to retrieve a summary of the conversation so far. + */ +export const SUMMARY_PROMPT = + 'Summarize all previous messages in a concise and comprehensive manner. The summary can be 3 words or less. Only respond with the summary and nothing else. Remember, the length of the summary can be 3 words or less.'; + +/** + * Default summary before summarization via LLM. + */ +export const DEFAULT_SUMMARY = 'Untitled'; + +/** + * The first message a User sees in a chat. + */ +export const WELCOME_PROMPT = "Hey there, what's on your mind?"; + +interface Config { + model: string; + engineId: number; + contextWindow: number; + temperature: number; + enabledMcpServers: string[]; +} + +interface Row { + id: number; + app_id: number; + summary: string; + config: string; + created: string; + modified: string; +} + +export default class Session extends Base('sessions') { + id?: number = $state(); + appId?: number = $state(); + summary: string = $state(DEFAULT_SUMMARY); + config: Partial = $state({}); + created?: moment.Moment = $state(); + modified?: moment.Moment = $state(); + + get default() { + const model = Model.default(); + return { + config: { + model: model.id, + engineId: model.engineId, + contextWindow: 4096, + temperature: 0.8, + enabledMcpServers: [], + }, + }; + } + + get app(): App | undefined { + if (!this.appId) return; + return App.find(this.appId); + } + + get messages(): Message[] { + if (!this.appId) return []; + return Message.where({ sessionId: this.id }); + } + + async tools(): Promise { + if (!this.id || !this.config?.model) { + return []; + } + + if (!Model.find(this.config.model)?.supportsTools) { + return []; + } + + return await getMCPTools(this.id); + } + + hasUserMessages(): boolean { + return Message.exists({ sessionId: this.id, role: 'user' }); + } + + hasMcpServer(server: string): boolean { + return !!this.config.enabledMcpServers?.includes(server); + } + + async addMessage(message: Partial): Promise { + return await Message.create({ + sessionId: this.id, + model: this.config.model, + ...message, + }); + } + + async addMcpServer(server: McpServer): Promise { + if (this.hasMcpServer(server.name)) { + return this; + } + + this.config.enabledMcpServers?.push(server.name); + return await this.save(); + } + + async removeMcpServer(server: McpServer): Promise { + this.config.enabledMcpServers = this.config.enabledMcpServers?.filter( + s => s !== server.name + ); + return await this.save(); + } + + async summarize() { + if (!this.id || !this.config.model || !this.config.engineId) { + return; + } + + if (!this.hasUserMessages() || this.summary !== DEFAULT_SUMMARY) { + return; + } + + const engine = Engine.find(this.config.engineId); + const model = engine.models.find(m => m.name == this.config.model); + + if (!engine || !engine.client || !model) { + return; + } + + const message: Message = await engine.client.chat(model, [ + ...this.messages, + { + role: 'user', + content: SUMMARY_PROMPT, + } as Message, + ]); + + // Some smaller models add extra explanation after a ";" + this.summary = message.content.split(';')[0]; + + // They also sometimes put the extra crap before "Summary: " + this.summary = this.summary.split(/[Ss]ummary: /).pop() as string; + + this.save(); + } + + protected async afterCreate(): Promise { + await Message.create({ + sessionId: this.id, + role: 'system', + content: Setting.CustomSystemPrompt?.trim() || SYSTEM_PROMPT, + }); + + await Message.create({ + sessionId: this.id, + role: 'assistant', + content: WELCOME_PROMPT, + }); + } + + protected static async fromSql(row: Row): Promise { + return Session.new({ + id: row.id, + appId: row.app_id, + summary: row.summary, + config: JSON.parse(row.config), + created: moment.utc(row.created), + modified: moment.utc(row.modified), + }); + } + + protected async toSql(): Promise> { + return { + app_id: Number(this.appId), + summary: this.summary, + config: JSON.stringify(this.config), + }; + } +} diff --git a/src/lib/models/session.ts b/src/lib/models/session.ts deleted file mode 100644 index 66950a5..0000000 --- a/src/lib/models/session.ts +++ /dev/null @@ -1,172 +0,0 @@ -import moment from 'moment'; - -import Engine from './engine'; - -import type { Tool } from '$lib/engines/types'; -import { getMCPTools } from '$lib/mcp'; -import App, { type IApp } from '$lib/models/app'; -import Base, { type ToSqlRow } from '$lib/models/base.svelte'; -import type { IMcpServer } from '$lib/models/mcp-server'; -import Message, { type IMessage } from '$lib/models/message'; -import Model from '$lib/models/model'; - -export const DEFAULT_SUMMARY = 'Untitled'; -export interface ISession { - id?: number; - appId?: number; - summary: string; - config: { - model: string; - engineId: number; - contextWindow: number; - temperature: number; - enabledMcpServers: string[]; - }; - created?: moment.Moment; - modified?: moment.Moment; -} - -interface Row { - id: number; - app_id: number; - summary: string; - config: string; - created: string; - modified: string; -} - -export default class Session extends Base('sessions') { - static defaults = () => { - const model = Model.default(); - return { - summary: DEFAULT_SUMMARY, - config: { - model: model.id, - engineId: model.engineId, - contextWindow: 4096, - temperature: 0.8, - enabledMcpServers: [], - }, - }; - }; - - static app(session: ISession): IApp | undefined { - if (!session.appId) return; - return App.find(session.appId); - } - - static messages(session: ISession): IMessage[] { - if (!session.id) return []; - return Message.where({ sessionId: session.id }); - } - - static async tools(session: ISession): Promise { - if (!Model.find(session.config.model)?.supportsTools) { - return []; - } - - return await getMCPTools(session); - } - - static async addMessage(session: ISession, message: Partial): Promise { - return await Message.create({ - sessionId: session.id, - model: session.config.model, - ...message, - }); - } - - static hasMcpServer(session: ISession, server: string): boolean { - return session.config.enabledMcpServers.includes(server); - } - - static async addMcpServer(session: ISession, server: IMcpServer): Promise { - if (this.hasMcpServer(session, server.name)) { - return session; - } - - session.config.enabledMcpServers.push(server.name); - return await this.update(session); - } - - static async removeMcpServer(session: ISession, server: IMcpServer): Promise { - session.config.enabledMcpServers = session.config.enabledMcpServers.filter( - s => s !== server.name - ); - return await this.update(session); - } - - static async summarize(session: ISession, modelId: string) { - if (!session.id) { - return; - } - - if (!this.hasUserMessages(session) || session.summary !== DEFAULT_SUMMARY) { - return; - } - - const engine = Engine.fromModelId(modelId); - const model = Model.find(modelId); - - if (!engine || !model || !engine.client) { - return; - } - - const message: IMessage = await engine.client.chat(model, [ - ...this.messages(session), - { - role: 'user', - content: - 'Summarize all previous messages in a concise and comprehensive manner. The summary can be 3 words or less. Only respond with the summary and nothing else. Remember, the length of the summary can be 3 words or less.', - } as IMessage, - ]); - - // Some smaller models add extra explanation after a ";" - session.summary = message.content.split(';')[0]; - - // They also sometimes put the extra crap before "Summary: " - session.summary = session.summary.split(/[Ss]ummary: /).pop() as string; - - this.update(session); - } - - static hasUserMessages(session: ISession): boolean { - return Message.exists({ sessionId: session.id, role: 'user' }); - } - - protected static async afterCreate(session: ISession): Promise { - await Message.create({ - sessionId: session.id, - role: 'system', - content: - 'You are Tome, created by Runebook, which is an software company located in Oakland, CA. You are a helpful assistant.', - }); - - await Message.create({ - sessionId: session.id, - role: 'assistant', - content: "Hey there, what's on your mind?", - }); - - return session; - } - - protected static async fromSql(row: Row): Promise { - return { - id: row.id, - appId: row.app_id, - summary: row.summary, - config: JSON.parse(row.config), - created: moment.utc(row.created), - modified: moment.utc(row.modified), - }; - } - - protected static async toSql(session: ISession): Promise> { - return { - app_id: session.appId as number, - summary: session.summary, - config: JSON.stringify(session.config), - }; - } -} diff --git a/src/lib/models/setting.svelte.ts b/src/lib/models/setting.svelte.ts new file mode 100644 index 0000000..95044b0 --- /dev/null +++ b/src/lib/models/setting.svelte.ts @@ -0,0 +1,91 @@ +import { Engine } from '$lib/models'; +import Base, { type ToSqlRow } from '$lib/models/base.svelte'; + +// OpenAI API Key +const OPENAI_API_KEY = 'openai-api-key'; + +// Gemini API Key +const GEMINI_API_KEY = 'gemini-api-key'; + +// Ollama URL +export const OLLAMA_URL_CONFIG_KEY = 'ollama-url'; + +// Custom System Prompt +export const CUSTOM_SYSTEM_PROMPT = 'custom-system-prompt'; +const COLOR_SCHEME_KEY = 'color-scheme'; + +export interface ISetting { + id?: number; + display: string; + key: string; + value: unknown; + type: string; +} + +interface Row { + id: number; + display: string; + key: string; + value: string; + type: string; +} + +export default class Setting extends Base('settings') { + id?: number = $state(); + display: string = $state(''); + key: string = $state(''); + value: unknown = $state(''); + type: string = $state(''); + + static get OllamaUrl(): string | undefined { + return this.findBy({ key: OLLAMA_URL_CONFIG_KEY })?.value as string | undefined; + } + + static get OpenAIKey(): string | undefined { + return this.findBy({ key: OPENAI_API_KEY })?.value as string | undefined; + } + + static get GeminiApiKey(): string | undefined { + return this.findBy({ key: GEMINI_API_KEY })?.value as string | undefined; + } + + static get CustomSystemPrompt(): string | undefined { + return this.findBy({ key: CUSTOM_SYSTEM_PROMPT })?.value as string | undefined; + } + + static get ColorScheme(): string | undefined { + return this.findBy({ key: COLOR_SCHEME_KEY })?.value as string | undefined; + } + + static set ColorScheme(value: string | undefined) { + const setting = this.findBy({ key: COLOR_SCHEME_KEY }); + if (setting && value) { + setting.value = value; + setting.save(); + } + } + + protected static async afterUpdate() { + // Resync models in case a Provider key/url was updated. + await Engine.sync(); + } + + protected static async fromSql(row: Row): Promise { + return Setting.new({ + id: row.id, + display: row.display, + key: row.key, + value: row.value ? JSON.parse(row.value) : null, + type: row.type, + }); + } + + protected async toSql(): Promise> { + return { + display: this.display, + key: this.key, + value: JSON.stringify(this.value), + type: this.type, + }; + } +} diff --git a/src/lib/models/setting.ts b/src/lib/models/setting.ts deleted file mode 100644 index 458ff6c..0000000 --- a/src/lib/models/setting.ts +++ /dev/null @@ -1,75 +0,0 @@ -import Ollama from '$lib/engines/ollama/client'; -import Model, { type ToSqlRow } from '$lib/models/base.svelte'; -import Engine from '$lib/models/engine'; - -// OpenAI API Key -const OPENAI_API_KEY = 'openai-api-key'; - -// Gemini API Key -const GEMINI_API_KEY = 'gemini-api-key'; - -// Ollama URL -export const OLLAMA_URL_CONFIG_KEY = 'ollama-url'; - -export interface ISetting { - id?: number; - display: string; - key: string; - value: unknown; - type: string; -} - -interface Row { - id: number; - display: string; - key: string; - value: string; - type: string; -} - -export default class Setting extends Model('settings') { - static get OllamaUrl(): string | undefined { - return this.findBy({ key: OLLAMA_URL_CONFIG_KEY })?.value as string | undefined; - } - - static get OpenAIKey(): string | undefined { - return this.findBy({ key: OPENAI_API_KEY })?.value as string | undefined; - } - - static get GeminiApiKey(): string | undefined { - return this.findBy({ key: GEMINI_API_KEY })?.value as string | undefined; - } - - static async validate(setting: ISetting): Promise { - if (setting.key == OLLAMA_URL_CONFIG_KEY) { - const client = new Ollama(setting.value as string); - return await client.connected(); - } - return true; - } - - protected static async afterUpdate(setting: ISetting): Promise { - // Resync models in case a Provider key/url was updated. - await Engine.sync(); - return setting; - } - - protected static async fromSql(row: Row): Promise { - return { - id: row.id, - display: row.display, - key: row.key, - value: row.value ? JSON.parse(row.value) : null, - type: row.type, - }; - } - - protected static async toSql(setting: ISetting): Promise> { - return { - display: setting.display, - key: setting.key, - value: JSON.stringify(setting.value), - type: setting.type, - }; - } -} diff --git a/src/lib/shellwords.ts b/src/lib/shellwords.ts new file mode 100644 index 0000000..ed7e339 --- /dev/null +++ b/src/lib/shellwords.ts @@ -0,0 +1,5 @@ +export * from 'shellwords'; + +export function join(args: string[]): string { + return args.map(arg => (arg.includes(' ') ? `"${arg}"` : arg)).join(' '); +} diff --git a/src/lib/tool-call-migration.ts b/src/lib/tool-call-migration.ts index 94304f7..55980f1 100644 --- a/src/lib/tool-call-migration.ts +++ b/src/lib/tool-call-migration.ts @@ -1,6 +1,6 @@ import uuid4 from 'uuid4'; -import Message from '$lib/models/message'; +import { Message } from '$lib/models'; export async function migrate() { await Promise.all( @@ -15,13 +15,13 @@ export async function migrate() { // now if that's the case. if (!tc.id) { tc.id = uuid4(); - await Message.save(message); + await message.save(); } // Tool responses are always the following message const response = Message.find(Number(message.id) + 1); response.toolCallId = tc.id; - await Message.save(response); + await response.save(); }) ); }) diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..ce8c9da --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,7 @@ +export interface CheckboxEvent extends Event { + currentTarget: EventTarget & HTMLInputElement; +} + +export interface ButtonEvent extends MouseEvent { + currentTarget: EventTarget & HTMLButtonElement; +} diff --git a/src/lib/updates.ts b/src/lib/updates.ts index fe222a7..a8cda5b 100644 --- a/src/lib/updates.ts +++ b/src/lib/updates.ts @@ -1,6 +1,6 @@ import { check, type Update } from '@tauri-apps/plugin-updater'; -import Config from '$lib/models/config'; +import { Config } from '$lib/models'; export async function availableUpdate(): Promise { return await check(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9c2a74f..a55bf50 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,8 +1,10 @@ @@ -59,16 +80,32 @@ {/snippet} -{#snippet McpServerView(server: IMcpServer)} +{#snippet McpServerView(server: McpServer)}

destroy(server)}> - - {server.name} - + {#if isRenaming && renamingServer?.id === server.id} +
{ e.preventDefault(); handleRename(); }} + > + e.stopPropagation()} + autofocus + /> +
+ {:else} + + {server.name} + + {/if}
{/snippet} diff --git a/src/routes/mcp-servers/+page.svelte b/src/routes/mcp-servers/+page.svelte index 896553a..c3337a4 100644 --- a/src/routes/mcp-servers/+page.svelte +++ b/src/routes/mcp-servers/+page.svelte @@ -1,9 +1,9 @@ diff --git a/src/routes/mcp-servers/install/+page.svelte b/src/routes/mcp-servers/install/+page.svelte index 075f364..10149a4 100644 --- a/src/routes/mcp-servers/install/+page.svelte +++ b/src/routes/mcp-servers/install/+page.svelte @@ -8,7 +8,7 @@ import Modal from '$components/Modal.svelte'; import Svg from '$components/Svg.svelte'; import type { VSCodeMcpInstallConfig } from '$lib/deeplinks'; - import McpServer from '$lib/models/mcp-server'; + import { McpServer } from '$lib/models'; const payload = page.url.searchParams.get('config') as string; const config: VSCodeMcpInstallConfig = JSON.parse(decodeURIComponent(payload)); diff --git a/src/routes/mcp-servers/new/+page.svelte b/src/routes/mcp-servers/new/+page.svelte index f3eb018..e6341d2 100644 --- a/src/routes/mcp-servers/new/+page.svelte +++ b/src/routes/mcp-servers/new/+page.svelte @@ -1,8 +1,8 @@ diff --git a/src/routes/mcp-servers/smithery/+page.svelte b/src/routes/mcp-servers/smithery/+page.svelte index d750f82..a009b3b 100644 --- a/src/routes/mcp-servers/smithery/+page.svelte +++ b/src/routes/mcp-servers/smithery/+page.svelte @@ -8,7 +8,7 @@ import Card from '$components/Smithery/Card.svelte'; import Configuration from '$components/Smithery/Configuration.svelte'; import type { McpConfig } from '$lib/mcp'; - import McpServer from '$lib/models/mcp-server'; + import McpServer from '$lib/models/mcp-server.svelte'; import { Client } from '$lib/smithery/client'; import type { CompactServer, ConfigSchema, Server } from '$lib/smithery/types'; import { debounce } from '$lib/util.svelte'; @@ -41,7 +41,7 @@ } function configSchemaFor(server: Server): ConfigSchema { - return server.connections.findBy('type', 'stdio')?.configSchema || {}; + return server.connections.findBy('type', 'stdio')?.configSchema || ({} as ConfigSchema); } async function configure(_server: CompactServer) { @@ -67,7 +67,7 @@ { + applyColorScheme(colorScheme); + }); + + async function ondelete(engine: Engine) { + await engine.delete(); + } + + function onsave(_: Engine) { adding = false; } @@ -34,8 +59,41 @@ {/snippet} - - + + + +
+

Color Scheme

+

Set the color scheme of Tome

+
+ + + + +
+ + +
+

Custom Prompt

+

+ Set a custom system prompt that will be used for all new conversations + instead of the default prompt. +

+
+ + + + +
+

Engines

diff --git a/tsconfig.json b/tsconfig.json index f61ecdd..2b14138 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,8 @@ "checkJs": true, "esModuleInterop": true, "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "inlineSourceMap": true,