Merge branch 'main' into tasks

This commit is contained in:
cam
2025-06-09 13:02:38 -04:00
committed by Matte Noble
84 changed files with 2053 additions and 1553 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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"
}

8
pnpm-lock.yaml generated
View File

@@ -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

2
src-tauri/Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "Tome"
version = "0.6.0"
version = "0.8.1"
dependencies = [
"anyhow",
"chrono",

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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<String> = 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(())
}

View File

@@ -18,6 +18,7 @@ type Service = RunningService<RoleClient, ()>;
pub struct McpServer {
service: Service,
pid: Pid,
custom_name: Option<String>,
}
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) -> <RoleClient as ServiceRole>::PeerInfo {

View File

@@ -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,
},
]
}

View File

@@ -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": {

View File

@@ -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);
}

8
src/app.d.ts vendored
View File

@@ -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' {

View File

@@ -7,6 +7,6 @@
const { children, class: cls = '', ...rest }: SvelteHTMLElements['div'] = $props();
</script>
<Flex class={twMerge('border-light mb-2 rounded-lg border p-4', cls?.toString())} {...rest}>
<Flex class={twMerge('border-light bg-medium mb-2 rounded-lg border p-4', cls?.toString())} {...rest}>
{@render children?.()}
</Flex>

View File

@@ -1,13 +1,42 @@
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import type {} from 'svelte/events';
import { twMerge } from 'tailwind-merge';
const { children, class: cls = '', ...rest }: HTMLButtonAttributes = $props();
import Spinner from '$components/Spinner.svelte';
import type { ButtonEvent } from '$lib/types';
interface Props extends HTMLButtonAttributes {
spinner?: boolean;
}
const {
children,
class: cls = '',
spinner = true,
onclick: _onclick,
...rest
}: Props = $props();
let waiting = $state(false);
async function onclick(e: ButtonEvent) {
if (_onclick) {
waiting = true;
await _onclick(e);
waiting = false;
}
}
</script>
<button
class={twMerge('rounded-md border p-2 px-6 text-sm hover:cursor-pointer', cls?.toString())}
{...rest}
>
{@render children?.()}
</button>
{#if waiting && spinner}
<Spinner />
{:else}
<button
class={twMerge('rounded-md border p-2 px-6 text-sm hover:cursor-pointer', cls?.toString())}
{onclick}
{...rest}
>
{@render children?.()}
</button>
{/if}

View File

@@ -1,19 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import Scrollable from './Scrollable.svelte';
import Flex from '$components/Flex.svelte';
import Message from '$components/Message.svelte';
import MessageView from '$components/Message.svelte';
import Scrollable from '$components/Scrollable.svelte';
import { dispatch } from '$lib/dispatch';
import type { IMessage } from '$lib/models/message';
import type { IModel } from '$lib/models/model';
import Session, { type ISession } from '$lib/models/session';
import { type IModel, Message, Session } from '$lib/models';
interface Props {
session: ISession;
session: Session;
model?: IModel;
onMessages?: (message: IMessage[]) => Promise<void>;
onMessages?: (message: Message[]) => Promise<void>;
}
const { session, model }: Props = $props();
@@ -25,7 +22,7 @@
let content: HTMLDivElement;
// Full history of chat messages in this session
const messages: IMessage[] = $derived(Session.messages(session));
const messages: Message[] = $derived(session.messages);
// Is the LLM processing (when true, we show the ellipsis)
let loading = $state(false);
@@ -100,7 +97,7 @@
<Flex id="messages" class="w-full flex-col items-start">
<!-- Svelte hack: ensure chat is always scrolled to the bottom when a new message is added -->
<div use:scrollToBottom class="hidden"></div>
<Message {message} />
<MessageView {message} />
</Flex>
{/each}
@@ -122,7 +119,7 @@
onkeydown={onChatInput}
disabled={!model}
placeholder="Message..."
class="disabled:text-dark item bg-dark border-light focus:border-purple/15 mb-8
class="disabled:text-dark item bg-medium border-light focus:border-purple/15 mb-8
h-auto w-[calc(100%-calc(var(--spacing)*6))] grow self-start rounded-xl border
p-3 pl-4 outline-0 transition duration-300"
></textarea>

View File

@@ -26,7 +26,7 @@
</Titlebar>
<section
class="w-content-minus-nav fixed top-[var(--height-titlebar)] left-0 ml-[var(--width-nav)] h-full min-h-screen"
class="w-content-minus-nav bg-medium fixed top-[var(--height-titlebar)] left-0 ml-[var(--width-nav)] h-full min-h-screen"
>
{@render children?.()}
</section>

View File

@@ -1,13 +1,17 @@
<script lang="ts">
import { constantCase } from 'change-case';
import { goto } from '$app/navigation';
import Button from './Button.svelte';
import Flex from '$components/Flex.svelte';
import Input from '$components/Input.svelte';
import Svg from '$components/Svg.svelte';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
import { McpServer } from '$lib/models';
import { join, split } from '$lib/shellwords';
interface Props {
server: IMcpServer;
server: McpServer;
}
let { server }: Props = $props();
@@ -18,24 +22,47 @@
let key: string = $state('');
let value: string = $state('');
$effect.pre(() => {
if (server) {
command = `${server.command} ${server.args.join(' ')}`.trim();
env = Object.entries(server.env);
}
});
let error: string | null = $state(null);
async function save() {
const cmd = command.split(' ');
error = null;
if (command == '') {
return;
}
const cmd = split(command);
server.command = cmd[0];
server.args = cmd.slice(1);
server.env = Object.fromEntries(env.map(([k, v]) => [constantCase(k), v]));
server = await McpServer.save(server);
try {
server = await server.save();
goto(`/mcp-servers/${server.id}`);
} catch (e) {
if (e instanceof Error) {
error = e.message;
}
if (typeof e == 'string') {
if (e.includes('initialize response')) {
error = 'mcp server could not initialize';
} else {
error = e;
}
}
return;
}
}
async function update() {
if (server.id) {
await save();
}
}
async function addEnv() {
env.push([key, value]);
await save();
key = '';
value = '';
}
@@ -44,48 +71,77 @@
env = env.filter(e => e[0] !== key);
await save();
}
$effect.pre(() => {
if (server) {
command = `${server.command} ${join(server.args)}`.trim();
env = Object.entries(server.env);
}
});
</script>
<Flex class="w-full flex-col items-start">
<h1 class="text-purple mb-4 ml-4 text-2xl">{server?.name}</h1>
{#if server?.id}
<h1 class="text-purple mb-8 ml-4 text-2xl">{server.name}</h1>
{:else}
<h1 class="text-light mb-8 ml-4 text-2xl">Add Server</h1>
{/if}
<h2 class="text-medium mb-4 ml-4 text-xl">Command</h2>
<h2 class="text-medium mt-8 mb-4 ml-4 text-xl">Command</h2>
<Input
bind:value={command}
label={false}
name="command"
class="w-full"
onchange={save}
onchange={update}
placeholder="uvx | npx COMMAND [args]"
/>
<h2 class="text-medium mt-8 mb-4 ml-4 text-xl">ENV</h2>
<Flex class="grid w-full auto-cols-max auto-rows-max grid-cols-2 gap-4">
{#each env, i (i)}
<Flex class="mt-8 mb-4 ml-4 items-center">
<h2 class="text-medium text-lg">ENV</h2>
<button class="border-light ml-4 h-6 w-6 rounded-md border">
<p class="text-medium h-6 w-6 pr-0.5 text-center text-[12px] !leading-[18px]">+</p>
</button>
</Flex>
{#each env, i (i)}
<Flex class="mb-4 w-full gap-4">
<Input
onchange={save}
onchange={update}
label={false}
placeholder="Key"
bind:value={env[i][0]}
class="uppercase"
/>
<Flex>
<Input onchange={save} label={false} placeholder="Value" bind:value={env[i][1]} />
<button
class="text-dark hover:text-red ml-4 transition duration-300 hover:cursor-pointer"
onclick={() => remove(env[i][0])}
>
<Svg name="Delete" class="h-4 w-4" />
</button>
</Flex>
{/each}
<Input onchange={update} label={false} placeholder="Value" bind:value={env[i][1]} />
<Input bind:value={key} label={false} placeholder="Key" class="uppercase" />
<Flex>
<Input bind:value onchange={addEnv} label={false} placeholder="Value" />
<div class="ml-4 h-4 w-4"></div>
<button
class="text-dark hover:text-red ml-4 transition duration-300 hover:cursor-pointer"
onclick={() => remove(env[i][0])}
>
<Svg name="Delete" class="h-4 w-4" />
</button>
</Flex>
{/each}
<Flex class="mb-8 w-full gap-4">
<Input bind:value={key} label={false} placeholder="Key" class="uppercase" />
<Input bind:value onchange={addEnv} label={false} placeholder="Value" />
<div class="w-16"></div>
</Flex>
{#if !server?.id}
<Flex class="w-full">
<Button class="border-purple text-purple" onclick={save}>Save</Button>
{#if error}
<Flex class="text-red ml-4">
<Svg name="Warning" class="mr-2 h-4 w-4" />
{error}
</Flex>
{/if}
</Flex>
{/if}
</Flex>

View File

@@ -57,7 +57,7 @@
<Flex
bind:ref={inner}
class={`${isOpen ? 'fixed' : 'hidden'} bg-light z-20 min-w-56 flex-col
rounded-lg border border-white/5 py-2 text-base shadow-md shadow-black/10 group-hover:block`}
rounded-lg border border-light py-2 text-base shadow-md shadow-black/10 group-hover:block`}
>
{#each items as item, i (i)}
<button

View File

@@ -2,10 +2,10 @@
import Assistant from '$components/Messages/Assistant.svelte';
import Tool from '$components/Messages/Tool.svelte';
import User from '$components/Messages/User.svelte';
import type { IMessage } from '$lib/models/message';
import { Message } from '$lib/models';
interface Props {
message: IMessage;
message: Message;
}
const { message }: Props = $props();

View File

@@ -2,10 +2,10 @@
import Flex from '$components/Flex.svelte';
import Thought from '$components/Messages/Thought.svelte';
import markdown from '$lib/markdown';
import type { IMessage } from '$lib/models/message';
import { Message } from '$lib/models';
interface Props {
message: IMessage;
message: Message;
}
const { message }: Props = $props();

View File

@@ -4,14 +4,14 @@
import Flex from '$components/Flex.svelte';
import Svg from '$components/Svg.svelte';
import markdown from '$lib/markdown';
import Message, { type IMessage } from '$lib/models/message';
import { Message } from '$lib/models';
interface Props {
message: IMessage;
message: Message;
}
const { message }: Props = $props();
const response = $derived(Message.response(message) as IMessage);
const response = $derived(message.response) as Message;
let isOpen = $state(false);
let css = $derived.by(() => (isOpen ? 'rounded-xl w-full' : 'rounded-full'));
@@ -20,7 +20,7 @@
isOpen = isOpen ? false : true;
}
function format(response: IMessage) {
function format(response: Message) {
try {
// Re-format JSON to be more readable
const json = JSON.parse(response.content);

View File

@@ -2,10 +2,10 @@
import { onMount } from 'svelte';
import Flex from '$components/Flex.svelte';
import type { IMessage } from '$lib/models/message';
import { Message } from '$lib/models';
interface Props {
message: IMessage;
message: Message;
}
const MAX_HEIGHT_PER_LINE = 28;

View File

@@ -25,7 +25,7 @@
});
</script>
<div class="fixed top-0 left-0 z-40 h-screen w-screen bg-black/90"></div>
<div class="fixed top-0 left-0 z-40 h-screen w-screen bg-dark/80"></div>
<div
bind:this={ref}

View File

@@ -3,11 +3,10 @@
import Flex from '$components/Flex.svelte';
import closables from '$lib/closables';
import type { IEngine } from '$lib/models/engine';
import type { IModel } from '$lib/models/model';
import { Engine, type IModel } from '$lib/models';
interface Props {
engines: IEngine[];
engines: Engine[];
selected?: IModel;
onselect?: (model: IModel) => Promise<void>;
}

View File

@@ -10,7 +10,7 @@
<nav
class={twMerge(
'bg-md text-medium border-light flex flex-col items-center gap-8 border-r pt-20',
'bg-medium text-medium border-light flex flex-col items-center gap-8 border-r pt-20',
cls?.toString()
)}
>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Box from '$components/Box.svelte';
import Textarea from '$components/Textarea.svelte';
import Setting, { CUSTOM_SYSTEM_PROMPT } from '$lib/models/setting.svelte';
interface Props {
saving?: boolean;
}
let { saving = $bindable(false) }: Props = $props();
let customPrompt = $state('');
let saveTimeout: number | undefined;
let isSaving = $state(false);
const MAX_PROMPT_LENGTH = 5000;
const isValid = $derived(customPrompt.length <= MAX_PROMPT_LENGTH);
// Load existing custom prompt on component mount
$effect(() => {
const existingPrompt = Setting.CustomSystemPrompt;
if (existingPrompt) {
customPrompt = existingPrompt;
}
});
onDestroy(() => {
if (saveTimeout) {
clearTimeout(saveTimeout);
}
});
async function save() {
// Prevent concurrent saves
if (isSaving || !isValid) return;
try {
isSaving = true;
const existingSetting = Setting.findBy({ key: CUSTOM_SYSTEM_PROMPT });
if (existingSetting) {
existingSetting.value = customPrompt;
await existingSetting.save();
} else {
await Setting.create({
display: 'Custom System Prompt',
key: CUSTOM_SYSTEM_PROMPT,
value: customPrompt,
type: 'string',
});
}
saving = true;
setTimeout(() => (saving = false), 2000);
} catch (error) {
console.error('Failed to save custom prompt:', error);
} finally {
isSaving = false;
}
}
function onBlur() {
if (saveTimeout) {
clearTimeout(saveTimeout);
saveTimeout = undefined;
}
save();
}
function onInput() {
if (saveTimeout) {
clearTimeout(saveTimeout);
}
// @ts-expect-error LSP thinks this is node
saveTimeout = setTimeout(() => {
saveTimeout = undefined;
save();
}, 2000);
}
</script>
<Box class="bg-medium w-full flex-col items-start gap-2">
<h3 class="font-medium">Custom System Prompt</h3>
<p class="text-medium text-sm">
Override the default system prompt. Leave empty to use the default prompt.
<strong>Note:</strong>
This will only apply to new conversations. Existing conversations will keep their original system
prompt.
</p>
<Textarea
label={false}
bind:value={customPrompt}
placeholder="You are a helpful assistant..."
oninput={onInput}
onblur={onBlur}
autocorrect="off"
class="mt-4 w-full {!isValid ? 'border-red-500' : ''}"
rows={4}
/>
<div class="flex w-full justify-between text-xs">
<span class="text-medium">
{customPrompt.length}/{MAX_PROMPT_LENGTH} characters
</span>
{#if !isValid}
<span class="text-red-500">Prompt is too long</span>
{/if}
</div>
</Box>

View File

@@ -6,23 +6,22 @@
import Flex from '$components/Flex.svelte';
import Input from '$components/Input.svelte';
import Svg from '$components/Svg.svelte';
import Client from '$lib/engines/openai/client';
import Engine, { type IEngine } from '$lib/models/engine';
import { Engine } from '$lib/models';
const NON_DELETEABLE_ENGINES = ['ollama', 'openai', 'gemini'];
const IMMUTABLE_URLS = ['openai', 'gemini'];
interface Props {
explicitSave?: boolean;
engine?: IEngine;
ondelete?: (engine: IEngine) => Promise<unknown> | unknown;
onsave?: (engine: IEngine) => Promise<unknown> | unknown;
engine?: Engine;
ondelete?: (engine: Engine) => Promise<unknown> | unknown;
onsave?: (engine: Engine) => Promise<unknown> | unknown;
saving?: boolean;
}
let {
explicitSave = false,
engine = $bindable(Engine.default()),
engine = $bindable(Engine.new()),
saving = $bindable(false),
ondelete = () => {},
onsave = () => {},
@@ -40,7 +39,6 @@
}
if (engine.type !== 'ollama' && engine.options.apiKey !== '') {
console.log(engine.options.apiKey);
valid = await validateConnected();
}
@@ -72,8 +70,7 @@
let valid = true;
try {
const client = Engine.client(engine) as Client;
const connected = await client.connected();
const connected = await engine.client?.connected();
if (!connected) throw 'ConnectionError';
connectionError = false;
} catch {
@@ -105,7 +102,7 @@
return;
}
await Engine.save(engine);
await engine.save();
await onsave(engine);
saving = true;
setTimeout(() => (saving = false), 2000);
@@ -118,7 +115,7 @@
<Flex class="w-full justify-between">
<Flex class="mb-2 items-center">
{#if NON_DELETEABLE_ENGINES.includes(engine.type)}
<Svg name={capitalCase(engine.type)} class="ml-2 h-4 w-4 text-white" />
<Svg name={capitalCase(engine.type)} class="text-light ml-2 h-4 w-4" />
{/if}
<Input

View File

@@ -5,7 +5,7 @@
import Modal from '$components/Modal.svelte';
import { info } from '$lib/logger';
import type { McpConfig } from '$lib/mcp';
import type { Config, ConfigSchema, Server } from '$lib/smithery';
import type { Config, ConfigSchema, Server } from '$lib/smithery/types';
interface Props {
server: Server;

View File

@@ -20,7 +20,7 @@
position: absolute;
inset: 0px;
border-radius: 50%;
border: 5px solid #fff;
border: 3px solid var(--text-color-medium);
animation: prixClipFix 2s linear infinite;
}

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import type { HTMLTextareaAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Flex from '$components/Flex.svelte';
interface Props extends HTMLTextareaAttributes {
ref?: HTMLTextAreaElement;
label: string | boolean;
required?: boolean;
value: string;
validate?: (value: string) => boolean;
}
let {
label,
name,
class: cls = '',
required = false,
ref = $bindable(),
value = $bindable(),
validate = () => true,
autocomplete,
autocorrect,
...rest
}: Props = $props();
let valid = $state(true);
</script>
<Flex class="w-full flex-col items-start">
{#if label}
<label class="text-medium mt-2 mb-1 ml-2 text-sm" for={name}>
{label}
{#if required}
<span class="text-red">*</span>
{/if}
</label>
{/if}
<textarea
class={twMerge(
'border-light min-h-[100px] w-full resize-y rounded-md border p-2 px-4 outline-none',
cls?.toString()
)}
{name}
class:border-red={!valid}
onkeyup={() => (valid = validate?.(value))}
onblur={() => (valid = validate?.(value))}
{autocomplete}
{autocorrect}
bind:value
bind:this={ref}
{...rest}
></textarea>
</Flex>

View File

@@ -10,7 +10,7 @@
<Flex
id="titlebar"
data-tauri-drag-region
class={twMerge('h-titlebar border-b-light w-full border-b', cls?.toString())}
class={twMerge('h-titlebar border-b-light bg-medium w-full border-b', cls?.toString())}
>
{@render children?.()}
</Flex>

View File

@@ -6,13 +6,16 @@
import Button from '$components/Button.svelte';
import Flex from '$components/Flex.svelte';
import Svg from '$components/Svg.svelte';
import Config from '$lib/models/config';
import Config from '$lib/models/config.svelte';
import { availableUpdate } from '$lib/updates';
let update: Update | null = $state(null);
let totalDownload: number = $state(0);
let downloaded: number = $state(0);
let completed = $state(0);
let finished = $state(false);
let message = $state(`Downloading... (0%)`);
// svelte-ignore non_reactive_update
let ref: HTMLDivElement;
@@ -27,17 +30,19 @@
case 'Progress':
downloaded += event.data.chunkLength / 1000.0;
ref.setAttribute(
'style',
`width:${Math.round((downloaded / totalDownload) * 100)}%`
);
completed = Math.round((downloaded / totalDownload) * 100);
message = `Downloading... (${completed}%)`;
ref.setAttribute('style', `width:${completed}%`);
break;
case 'Finished':
finished = true;
message = 'Installing...';
break;
}
});
message = 'Complete';
finished = true;
}
async function skip() {
@@ -61,10 +66,14 @@
<Flex class="w-full flex-col items-start">
<h2 class="font-semibold">Installing Tome {update.version}</h2>
<div class="bg-light mt-4 h-2 w-full rounded-full">
<div bind:this={ref} style="width: 0%" class="bg-purple h-2 rounded-full"></div>
<div
bind:this={ref}
style={`width: ${completed}%`}
class="bg-purple h-2 rounded-full"
></div>
</div>
<p class="text-medium mt-2 text-xs">
{Math.round(downloaded)} kb / {Math.round(totalDownload)} kb
{message}
</p>
{#if finished}

View File

@@ -3,10 +3,12 @@
import { platform } from '@tauri-apps/plugin-os';
import Flex from '$components/Flex.svelte';
import Config from '$lib/models/config';
import Config from '$lib/models/config.svelte';
async function accept() {
await Config.set('welcome-agreed', true);
const config = Config.findByOrNew({ key: 'welcome-agreed' });
config.value = true;
await config.save();
if (platform() == 'windows') {
await invoke('restart');
@@ -14,10 +16,10 @@
}
</script>
<Flex class="w-full items-start">
<Flex class="w-full items-start overflow-hidden">
<img class="mr-12 h-48 w-48" src="/images/tome.png" alt="tome" />
<Flex class="flex-col items-start gap-4">
<Flex class="max-w-[400px] flex-col items-start gap-4 overflow-y-scroll">
<h1 class="text-purple text-3xl">Welcome to Tome</h1>
<p>
Thanks for being an early adopter! We appreciate you kicking the tires of our
@@ -31,7 +33,8 @@
</p>
<button
onclick={() => accept()}
class="from-purple-dark to-purple mt-2 rounded-md bg-linear-to-t p-1 px-4 hover:cursor-pointer"
class="from-purple-dark to-purple mt-2 rounded-md bg-linear-to-t
p-1 px-4 font-medium hover:cursor-pointer"
>
Sounds good, let's go!
</button>

View File

@@ -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();

View File

@@ -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<IMessage> {
export async function dispatch(session: Session, model: IModel, prompt?: string): Promise<Message> {
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;
}

View File

@@ -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<IMessage> {
const messages = history.map(m => GeminiMessage.from(m)).compact();
): Promise<Message> {
// 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<IModel[]> {
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<boolean> {
return true; // Assume Gemini is up
try {
await this.client.models.list();
return true;
} catch {
return false;
}
}
}

View File

@@ -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: [

View File

@@ -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<IMessage> {
): Promise<Message> {
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<boolean> {
return (await fetch(new URL(this.options.url).origin, { timeout: 200 })).status == 200;
try {
return (await this.models()) && true;
} catch {
return false;
}
}
}

View File

@@ -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,

View File

@@ -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<IMessage> {
_options: Options = {}
): Promise<Message> {
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<IModel[]> {
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<boolean> {
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;
}
}
}

View File

@@ -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;
}

View File

@@ -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<IMessage>;
chat(
model: IModel,
history: TomeMessage[],
tools?: Tool[],
options?: Options
): Promise<TomeMessage>;
models(): Promise<IModel[]>;
info(model: string): Promise<IModel>;
connected(): Promise<boolean>;
}
export interface ClientOptions {
engine: IEngine;
apiKey: string;
url: string;
}
export interface ClientProps extends ClientOptions {
engineId: number;
}
export interface Options {
num_ctx?: number;
temperature?: number;

View File

@@ -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<IMessage> {
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('<think>')) {
[thought, content] = content.split('</think>');
thought = thought.replace('<think>', '').trim();
content = content.trim();
}
return {
model,
role: 'assistant',
content,
thought,
name: '',
toolCalls: response.message.tool_calls || [],
};
}
async list(): Promise<OllamaModel[]> {
return ((await this.get('/api/tags')) as OllamaTags).models as OllamaModel[];
}
async info(name: string): Promise<OllamaModel> {
const body = JSON.stringify({ name });
return (await this.post('/api/show', { body })) as OllamaModel;
}
async connected(): Promise<boolean> {
return (
(
(await this.get('', {
raw: true,
timeout: 500,
})) as globalThis.Response
).status == 200
);
}
async hasModels(): Promise<boolean> {
if (!(await this.connected())) {
return false;
}
return ((await this.get('/api/tags')) as OllamaTags).models.length > 0;
}
}

18
src/lib/mcp.d.ts vendored
View File

@@ -1,18 +0,0 @@
export interface McpConfig {
command: string;
args: string[];
env: Record<string, string>;
}
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[];
}

View File

@@ -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<string, string>;
}
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<Tool[]> {
return (await invoke<McpTool[]>('get_mcp_tools', { sessionId: session.id })).map(tool => {
export async function getMCPTools(sessionId: number): Promise<Tool[]> {
return (await invoke<McpTool[]>('get_mcp_tools', { sessionId })).map(tool => {
return {
type: 'function',
function: {

View File

@@ -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<Row>('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<App> {
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<ToSqlRow<Row>> {
return {
name: this.name,
description: this.description,
readme: this.readme,
image: this.image,
interface: this.interface,
nodes: JSON.stringify(this.nodes),
};
}
}

View File

@@ -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<IApp, Row>('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<IMcpServer[]> {
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<IMcpServer[]> {
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<IApp> {
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<ToSqlRow<Row>> {
return {
name: app.name,
description: app.description,
readme: app.readme,
image: app.image,
interface: app.interface,
nodes: JSON.stringify(app.nodes),
};
}
}

View File

@@ -0,0 +1,40 @@
/**
* Model class NOT backed by a database
*/
export default function BareModel<T extends Obj>() {
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>): 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];
}
};
}

View File

@@ -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<Row> = Omit<Row, 'id' | 'created' | 'modified'>;
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<R> = Omit<R, 'id' | 'created' | 'modified'>;
/**
* # 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<Interface, Row>('messages') {
* class Message extends Base<Row>('messages') {
* userId: string = $state('');
* content: string = $state('');
*
* static function fromSql(row: Row): Promise<IMessage> {
* 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<ToSqlRow<Row>> {
* function toSql(): Promise<ToSqlRow<Row>> {
* return {
* user_id: message.rowId,
* content: message.content,
* user_id: this.rowId,
* content: this.content,
* }
* }
* }
* ```
*/
export default function Model<Interface extends Obj, Row extends Obj>(table: string) {
let repo: Interface[] = $state([]);
export default function Model<R extends object>(table: string) {
class ModelClass {
id?: number;
return class Model {
static defaults = {};
/**
* Reload records from the database and populate the Repository.
*/
static async sync(): Promise<void> {
repo = [];
(await this.query(`SELECT * FROM ${table}`)).forEach(record => this.syncOne(record));
info(`[green]✔ synced ${table}`);
constructor(params: Partial<object>, 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> = {}): Interface {
defaults =
typeof this.defaults == 'function'
? { ...this.defaults(), ...defaults }
: { ...this.defaults, ...defaults };
static new<T extends typeof ModelClass>(
this: T,
params: Partial<InstanceType<T>> = {}
): InstanceType<T> {
const inst = new this({}, true);
Object.assign(inst, inst.default);
Object.assign(inst, params);
return inst as InstanceType<T>;
}
return defaults as Interface;
static async create<T extends typeof ModelClass>(
this: T,
params: Partial<InstanceType<T>> = {}
): Promise<InstanceType<T>> {
return await this.new(params).save();
}
/**
* Does a record with specific params exist.
*/
static exists(params: Partial<Interface>): boolean {
static exists<T extends typeof ModelClass>(
this: T,
params: Partial<InstanceType<T>>
): boolean {
return this.where(params).length > 0;
}
/**
* Retrieve all records.
*/
static all(): Interface[] {
return repo;
static all<T extends typeof ModelClass>(this: T): InstanceType<T>[] {
return repo as InstanceType<T>[];
}
/**
* 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<T extends typeof ModelClass>(this: T, id: number): InstanceType<T> {
return this.all().find(m => m.id == Number(id)) as InstanceType<T>;
}
/**
* Find the first occurence by a subset of the model's properties.
*/
static findBy(params: Partial<Interface>): Interface | undefined {
static findBy<T extends typeof ModelClass>(
this: T,
params: Partial<InstanceType<T>>
): InstanceType<T> | undefined {
return this.where(params)[0];
}
/**
* Find by specific properties or instantiate a new instance with them.
*/
static findByOrNew<T extends typeof ModelClass>(
this: T,
params: Partial<InstanceType<T>>
): InstanceType<T> {
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>): Interface[] {
return repo.filter(m => {
return Object.entries(params).every(([key, value]) => m[key] == value);
});
static where<T extends typeof ModelClass>(
this: T,
params: Partial<InstanceType<T>>
): InstanceType<T>[] {
return repo.filter(m =>
Object.entries(params).every(([k, v]) => (m as Obj)[k] == v)
) as InstanceType<T>[];
}
/**
* Find the first record
*/
static first(): Interface {
return repo[0];
static first<T extends typeof ModelClass>(this: T): InstanceType<T> {
return repo[0] as InstanceType<T>;
}
/**
* Find the last record
*/
static last(): Interface {
return repo[repo.length - 1];
static last<T extends typeof ModelClass>(this: T): InstanceType<T> {
return repo[repo.length - 1] as InstanceType<T>;
}
static async deleteBy<T extends typeof ModelClass>(
this: T,
params: Partial<InstanceType<T>>
): Promise<boolean> {
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<boolean> {
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<Interface extends Obj, Row extends Obj>(table: str
*
* If `params` contains `id`, it will update, otherwise create.
*/
static async save(params: Interface): Promise<Interface> {
if (params.id) {
return await this.update(params);
} else {
return await this.create(params);
}
async save(): Promise<this> {
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<this> {
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<this>(
`
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<Interface extends Obj, Row extends Obj>(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<Interface>): Promise<Interface> {
let row = await this.toSql(
this.exclude(
{
...this.default(),
..._params,
},
ReservedColumns
)
);
private async create(): Promise<this> {
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<this>(
`
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<Interface> {
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<boolean> {
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<Row>): Promise<boolean> {
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<Interface[]> {
const result: Row[] = await (await this.db()).select<Row[]>(sql, values);
return await Promise.all(result.map(async row => await this.fromSql(row)));
}
/**
* Memoized database connection.
*/
protected static async db(): Promise<Database> {
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<T extends typeof ModelClass>(this: T, instance: InstanceType<T>) {
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<T>(sql: string, values: unknown[] = []): Promise<T[]> {
await connect();
const rows = await db.select<R[]>(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<T extends Obj>(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<P extends Obj>(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<P extends Obj>(params: P): string[] {
return Object.keys(params).map((k, i) => `${k} = $${i + 1} `);
}
/**
* Individual numeric bind statements, like`['$1', '$2']`.
*/
private static binds<P extends Obj>(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<Interface> {
throw 'NotImplementedError';
protected static async fromSql(row: R): Promise<unknown> {
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<Interface>): Promise<ToSqlRow<Row>> {
throw 'NotImplementedError';
protected async toSql(): Promise<ToSqlRow<R>> {
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<Row>): Promise<ToSqlRow<Row>> {
protected async beforeSave(row: ToSqlRow<R>): Promise<ToSqlRow<R>> {
return row;
}
/**
* Transform the `Instance` object after it's created/updated/retrieved
* from the database.
*/
protected static async afterSave(instance: Interface): Promise<Interface> {
return instance;
}
protected static async beforeCreate(row: ToSqlRow<Row>): Promise<ToSqlRow<Row>> {
protected async beforeCreate(row: ToSqlRow<R>): Promise<ToSqlRow<R>> {
return row;
}
protected static async afterCreate(instance: Interface): Promise<Interface> {
return instance;
}
protected static async beforeUpdate(row: ToSqlRow<Row>): Promise<ToSqlRow<Row>> {
protected async beforeUpdate(row: ToSqlRow<R>): Promise<ToSqlRow<R>> {
return row;
}
protected static async afterUpdate(instance: Interface): Promise<Interface> {
return instance;
protected async afterCreate(): Promise<void> {
// noop
}
};
protected async afterUpdate(): Promise<void> {
// noop
}
protected async afterSave(): Promise<void> {
// noop
}
}
/**
* Cache of model instances
*/
let repo: InstanceType<typeof ModelClass>[] = $state([]);
return ModelClass;
}
/**
* Model class NOT backed by a database
*/
export function BareModel<T extends Obj>() {
let repo: T[] = $state([]);
class Query<R extends Obj> {
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>): 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);
}
}

View File

@@ -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<Row>('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<Config> {
return Config.new({
id: row.id,
key: row.key as ConfigKey,
value: JSON.parse(row.value),
});
}
protected async toSql(): Promise<ToSqlRow<Row>> {
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,
});
};
}

View File

@@ -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<IConfig, Row>('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<IConfig> {
return {
id: row.id,
key: row.key,
value: JSON.parse(row.value),
};
}
protected static async toSql(config: IConfig): Promise<ToSqlRow<Row>> {
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,
});
};
}

View File

@@ -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<EngineType, 'all' | string[]> = {
'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<Row>('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<Engine> {
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<ToSqlRow<Row>> {
return {
name: this.name,
type: this.type,
options: JSON.stringify(this.options),
};
}
}

View File

@@ -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<IEngine['type'], 'all' | string[]> = {
'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<IEngine, Row>('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<IEngine> {
await Model.sync();
return engine;
}
protected static async fromSql(row: Row): Promise<IEngine> {
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<IEngine>): Promise<ToSqlRow<Row>> {
return {
name: engine.name,
type: engine.type,
options: JSON.stringify(engine.options),
};
}
}

View File

@@ -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();
}

View File

@@ -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<string, any>; // eslint-disable-line
};
serverInfo: {
name?: string;
version: string;
};
}
export default class McpServer extends Base<Row>('mcp_servers') {
id?: number = $state();
name: string = $state('Installing...');
command: string = $state('');
metadata?: Metadata = $state({} as Metadata);
args: string[] = $state([]);
env: Record<string, string> = $state({});
get defaults() {
return {
name: 'Installing...',
command: '',
metadata: {
protocolVersion: '',
capabilities: {
tools: {},
},
serverInfo: {
name: undefined,
version: '',
},
},
args: [],
env: {},
};
}
static async forApp(appId: number): Promise<McpServer[]> {
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<ToSqlRow<Row>> {
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<McpServer> {
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<ToSqlRow<Row>> {
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();
}
}
}
}

View File

@@ -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<string, string>;
}
interface Row {
id: number;
name: string;
command: string;
metadata: string;
args: string;
env: string;
}
interface Metadata {
protocolVersion: string;
capabilities: {
tools: Record<string, any>; // eslint-disable-line
};
serverInfo: {
name?: string;
version: string;
};
}
export default class McpServer extends Model<IMcpServer, Row>('mcp_servers') {
static defaults = {
name: 'Installing...',
command: '',
metadata: {
protocolVersion: '',
capabilities: {
tools: {},
},
serverInfo: {
name: undefined,
version: '',
},
},
args: [],
env: {},
};
static async forApp(appId: number): Promise<IMcpServer[]> {
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<IMcpServer> {
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<IMcpServer> {
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<ToSqlRow<Row>> {
return {
name: server.name,
command: server.command,
metadata: JSON.stringify(server.metadata),
args: JSON.stringify(server.args),
env: JSON.stringify(server.env),
};
}
}

View File

@@ -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<Row>('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<Message> {
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<ToSqlRow<Row>> {
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,
};
}
}

View File

@@ -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<IMessage, Row>('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<IMessage> {
const session = Session.find(message.sessionId as number);
await Session.summarize(session, session.config.model);
return message;
}
protected static async fromSql(row: Row): Promise<IMessage> {
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<ToSqlRow<Row>> {
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,
};
}
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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<IModel>() {
}
static default(): IModel {
return this.find(Config.defaultModel) || Engine.first().models[0];
return this.find(Config.defaultModel) || this.first();
}
static findByOrDefault(params: Partial<IModel>): IModel {

View File

@@ -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<Row>('sessions') {
id?: number = $state();
appId?: number = $state();
summary: string = $state(DEFAULT_SUMMARY);
config: Partial<Config> = $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<Tool[]> {
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<Message>): Promise<Message> {
return await Message.create({
sessionId: this.id,
model: this.config.model,
...message,
});
}
async addMcpServer(server: McpServer): Promise<Session> {
if (this.hasMcpServer(server.name)) {
return this;
}
this.config.enabledMcpServers?.push(server.name);
return await this.save();
}
async removeMcpServer(server: McpServer): Promise<Session> {
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<void> {
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<Session> {
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<ToSqlRow<Row>> {
return {
app_id: Number(this.appId),
summary: this.summary,
config: JSON.stringify(this.config),
};
}
}

View File

@@ -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<ISession, Row>('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<Tool[]> {
if (!Model.find(session.config.model)?.supportsTools) {
return [];
}
return await getMCPTools(session);
}
static async addMessage(session: ISession, message: Partial<IMessage>): Promise<IMessage> {
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<ISession> {
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<ISession> {
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<ISession> {
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<ISession> {
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<ToSqlRow<Row>> {
return {
app_id: session.appId as number,
summary: session.summary,
config: JSON.stringify(session.config),
};
}
}

View File

@@ -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<Row>('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<Setting> {
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<ToSqlRow<Row>> {
return {
display: this.display,
key: this.key,
value: JSON.stringify(this.value),
type: this.type,
};
}
}

View File

@@ -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<ISetting, Row>('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<boolean> {
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<ISetting> {
// Resync models in case a Provider key/url was updated.
await Engine.sync();
return setting;
}
protected static async fromSql(row: Row): Promise<ISetting> {
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<ToSqlRow<Row>> {
return {
display: setting.display,
key: setting.key,
value: JSON.stringify(setting.value),
type: setting.type,
};
}
}

5
src/lib/shellwords.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from 'shellwords';
export function join(args: string[]): string {
return args.map(arg => (arg.includes(' ') ? `"${arg}"` : arg)).join(' ');
}

View File

@@ -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();
})
);
})

7
src/lib/types.ts Normal file
View File

@@ -0,0 +1,7 @@
export interface CheckboxEvent extends Event {
currentTarget: EventTarget & HTMLInputElement;
}
export interface ButtonEvent extends MouseEvent {
currentTarget: EventTarget & HTMLButtonElement;
}

View File

@@ -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<Update | null> {
return await check();

View File

@@ -1,8 +1,10 @@
<!-- App-wide event handlers -->
<script lang="ts">
import { onMount } from 'svelte';
import { goto, onNavigate } from '$app/navigation';
import closables from '$lib/closables';
import { Setting } from '$lib/models';
import { resync } from '$lib/models';
const { children } = $props();
@@ -25,6 +27,10 @@
}
}
onMount(() => {
document.documentElement.setAttribute('data-theme', Setting.ColorScheme as string);
});
onNavigate(navigation => {
if (!document.startViewTransition) {
return;

View File

@@ -52,7 +52,7 @@
<Layout>
{#if check}
<Modal>
<Modal class="w-[700px] overflow-y-scroll xl:w-[700px]">
{#if check[0] == StartupCheck.Agreement}
<Welcome />
{:else if check[0] == StartupCheck.UpdateAvailable}

View File

@@ -13,13 +13,13 @@
import ModelMenu from '$components/ModelMenu.svelte';
import Svg from '$components/Svg.svelte';
import Toggle from '$components/Toggle.svelte';
import Engine, { type IEngine } from '$lib/models/engine';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
import Message from '$lib/models/message';
import Engine from '$lib/models/engine.svelte';
import McpServer from '$lib/models/mcp-server.svelte';
import Message from '$lib/models/message.svelte';
import Model, { type IModel } from '$lib/models/model';
import Session, { type ISession } from '$lib/models/session';
import Session from '$lib/models/session.svelte';
const session: ISession = $derived(Session.find(page.params.session_id));
const session: Session = $derived(Session.find(Number(page.params.session_id)));
const model: IModel | undefined = $derived(
Model.findBy({
id: session.config.model,
@@ -27,9 +27,9 @@
})
);
const sessions: ISession[] = $derived(Session.all());
const mcpServers: IMcpServer[] = $derived(McpServer.all());
const engines: IEngine[] = $derived(Engine.all());
const sessions: Session[] = $derived(Session.all());
const mcpServers: McpServer[] = $derived(McpServer.all());
const engines: Engine[] = $derived(Engine.all());
const hasModels = $derived(engines.flatMap(e => e.models).length > 0);
let advancedIsOpen = $state(false);
@@ -37,11 +37,11 @@
async function modelDidUpdate(model: IModel) {
session.config.model = model.id;
session.config.engineId = model.engineId;
await Session.update(session);
await session.save();
}
async function startMcpServers(session: ISession) {
session.config.enabledMcpServers.forEach(async name => {
async function startMcpServers(session: Session) {
session.config.enabledMcpServers?.forEach(async name => {
const server = mcpServers.find(s => s.name == name);
if (server) {
@@ -50,14 +50,14 @@
});
}
async function startMcpServer(server: IMcpServer) {
await McpServer.start(server, session);
await Session.addMcpServer(session, server);
async function startMcpServer(server: McpServer) {
await server.start(session);
await session.addMcpServer(server);
}
async function stopMcpServer(server: IMcpServer) {
await McpServer.stop(server, session);
await Session.removeMcpServer(session, server);
async function stopMcpServer(server: McpServer) {
await server.stop(session);
await session.removeMcpServer(server);
}
async function addSession() {
@@ -65,24 +65,24 @@
await goto(`/chat/${session.id}`);
}
async function deleteSession(sess: ISession) {
async function deleteSession(sess: Session) {
if (!sess.id) return;
await invoke('stop_session', { sessionId: sess.id });
await Message.deleteBy({ session_id: sess.id });
await Session.delete(sess.id as number);
await Message.deleteBy({ sessionId: sess.id });
await sess.delete();
await goto(`/chat/${sessions[sessions.length - 1].id}`);
}
async function saveSession() {
await Session.save(session);
await session.save();
}
function toggleAdvanced() {
advancedIsOpen = advancedIsOpen ? false : true;
}
function menuItems(session: ISession): MenuItem[] {
function menuItems(session: Session): MenuItem[] {
return [
{
label: 'Delete',
@@ -180,8 +180,7 @@
<Flex class="text-light z-0 mb-4 ml-2">
<Toggle
label={server.name}
value={Session.hasMcpServer(session, server.name) &&
model?.supportsTools
value={session.hasMcpServer(server.name) && model?.supportsTools
? 'on'
: 'off'}
disabled={!model?.supportsTools}

View File

@@ -3,9 +3,9 @@ import { goto } from '$app/navigation';
import type { PageLoad } from './$types';
import { CHAT_APP_ID } from '$lib/const';
import Config from '$lib/models/config';
import Config from '$lib/models/config.svelte';
import Model from '$lib/models/model';
import Session from '$lib/models/session';
import Session from '$lib/models/session.svelte';
export const load: PageLoad = async ({ params }): Promise<void> => {
await Model.sync();
@@ -14,7 +14,7 @@ export const load: PageLoad = async ({ params }): Promise<void> => {
const session = await Session.create({ appId: CHAT_APP_ID });
await goto(`/chat/${session.id}`);
} else if (params.session_id == 'latest') {
let session = Session.find(Config.latestSessionId);
let session = Session.find(Number(Config.latestSessionId));
session ||= Session.last();
session ||= await Session.create({ appId: CHAT_APP_ID });
await goto(`/chat/${session.id}`);

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { invoke } from '@tauri-apps/api/core';
import Deleteable from '$components/Deleteable.svelte';
import Flex from '$components/Flex.svelte';
@@ -10,7 +11,7 @@
import Menu from '$components/Menu.svelte';
import Svg from '$components/Svg.svelte';
import Titlebar from '$components/Titlebar.svelte';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
import { McpServer } from '$lib/models';
interface Registry {
name: string;
@@ -19,7 +20,7 @@
const { children } = $props();
let mcpServers: IMcpServer[] = $derived(McpServer.all());
let mcpServers: McpServer[] = $derived(McpServer.all());
let registries: Registry[] = [
{
name: 'Smithery',
@@ -27,8 +28,20 @@
},
];
function items(server: IMcpServer): MenuItem[] {
let isRenaming = $state(false);
let newName = $state('');
let renamingServer: McpServer | null = $state(null);
function items(server: McpServer): MenuItem[] {
return [
{
label: 'Rename',
onclick: () => {
newName = server.name;
renamingServer = server;
isRenaming = true;
}
},
{
label: 'Delete',
style: 'text-red hover:bg-red hover:text-white',
@@ -37,8 +50,16 @@
];
}
async function destroy(server: IMcpServer) {
await McpServer.delete(server.id as number);
async function handleRename() {
if (renamingServer && newName && newName !== renamingServer.name) {
await renamingServer.rename(newName);
}
isRenaming = false;
renamingServer = null;
}
async function destroy(server: McpServer) {
await server.delete();
goto(`/mcp-servers`);
}
</script>
@@ -59,16 +80,32 @@
</Titlebar>
{/snippet}
{#snippet McpServerView(server: IMcpServer)}
{#snippet McpServerView(server: McpServer)}
<Menu items={items(server)}>
<Deleteable ondelete={() => destroy(server)}>
<Link
href={`/mcp-servers/${server.id}`}
class="w-full py-3 pl-8 text-sm hover:cursor-pointer"
activeClass="text-purple border-l border-l-purple"
>
{server.name}
</Link>
{#if isRenaming && renamingServer?.id === server.id}
<form
class="w-full py-3 pl-8"
onsubmit={e => { e.preventDefault(); handleRename(); }}
>
<input
type="text"
bind:value={newName}
class="w-full bg-transparent text-sm outline-none"
onblur={handleRename}
onkeydown={e => e.stopPropagation()}
autofocus
/>
</form>
{:else}
<Link
href={`/mcp-servers/${server.id}`}
class="w-full py-3 pl-8 text-sm hover:cursor-pointer"
activeClass="text-purple border-l border-l-purple"
>
{server.name}
</Link>
{/if}
</Deleteable>
</Menu>
{/snippet}

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
import { McpServer } from '$lib/models';
const server: IMcpServer = McpServer.last();
const server: McpServer = McpServer.last();
if (server) {
goto(`/mcp-servers/${server.name}`);

View File

@@ -2,9 +2,9 @@
import { page } from '$app/state';
import McpServerView from '$components/McpServer.svelte';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
import McpServer from '$lib/models/mcp-server.svelte';
const server: IMcpServer = $derived(McpServer.find(page.params.id));
const server: McpServer = $derived(McpServer.find(Number(page.params.id)));
</script>
<McpServerView {server} />

View File

@@ -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));

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import McpServerView from '$components/McpServer.svelte';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
import { McpServer } from '$lib/models';
const server: IMcpServer = $state(McpServer.default());
const server: McpServer = $state(McpServer.new());
</script>
<McpServerView {server} />

View File

@@ -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 @@
<Flex class="w-full">
<Input
bind:value={query}
class="mb-8"
class="placeholder:text-light mb-8"
label={false}
name="search"
onkeyup={debounce(search)}

View File

@@ -6,10 +6,9 @@
import Flex from '$components/Flex.svelte';
import Layout from '$components/Layouts/Default.svelte';
import Svg from '$components/Svg.svelte';
import { Engine, type IEngine, type IModel } from '$lib/models';
import Config from '$lib/models/config';
import { Config, Engine, type IModel } from '$lib/models';
const engines: IEngine[] = Engine.all();
const engines: Engine[] = Engine.all();
function isDefault(model: IModel) {
return Config.defaultModel == model.id;

View File

@@ -3,21 +3,46 @@
import Flex from '$components/Flex.svelte';
import Layout from '$components/Layouts/Default.svelte';
import Scrollable from '$components/Scrollable.svelte';
import CustomPromptView from '$components/Settings/CustomPrompt.svelte';
import EngineView from '$components/Settings/Engine.svelte';
import Svg from '$components/Svg.svelte';
import Titlebar from '$components/Titlebar.svelte';
import Engine, { type IEngine } from '$lib/models/engine';
import Engine from '$lib/models/engine.svelte';
import Setting from '$lib/models/setting.svelte';
const engines: IEngine[] = $derived(Engine.all());
const engines: Engine[] = $derived(Engine.all());
let adding = $state(false);
let saving = $state(false);
async function ondelete(engine: IEngine) {
await Engine.delete(engine.id);
// Color scheme state
let colorScheme = $state(Setting.ColorScheme ?? 'system');
function onColorSchemeChange(e: Event) {
colorScheme = (e.target as HTMLSelectElement).value;
Setting.ColorScheme = colorScheme;
applyColorScheme(colorScheme);
}
function onsave(_: IEngine) {
function applyColorScheme(scheme: string) {
if (scheme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', scheme);
}
}
// Apply on load and whenever colorScheme changes
$effect(() => {
applyColorScheme(colorScheme);
});
async function ondelete(engine: Engine) {
await engine.delete();
}
function onsave(_: Engine) {
adding = false;
}
</script>
@@ -34,8 +59,41 @@
{/snippet}
<Layout {titlebar}>
<Scrollable class="!h-content">
<Flex class="w-full flex-col gap-4 overflow-y-auto p-8">
<Scrollable class="!h-content bg-medium">
<Flex class="w-full flex-col gap-8 overflow-y-auto p-8">
<Flex class="w-full items-start gap-4">
<section class="w-2/5">
<h2 class="font-semibold uppercase">Color Scheme</h2>
<p class="text-medium font-light">Set the color scheme of Tome</p>
</section>
<Flex class="w-full flex-col items-start gap-2">
<select
class="border-light bg-medium text-light mt-2 rounded-md border p-2"
bind:value={colorScheme}
onchange={onColorSchemeChange}
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</Flex>
</Flex>
<Flex class="w-full items-start gap-4">
<section class="w-2/5">
<h2 class="font-semibold uppercase">Custom Prompt</h2>
<p class="text-medium font-light">
Set a custom system prompt that will be used for all new conversations
instead of the default prompt.
</p>
</section>
<Flex class="w-full flex-col items-start">
<CustomPromptView bind:saving />
</Flex>
</Flex>
<Flex class="w-full items-start gap-4">
<section class="w-2/5">
<h2 class="font-semibold uppercase">Engines</h2>

View File

@@ -5,6 +5,8 @@
"checkJs": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"inlineSourceMap": true,