mirror of
https://github.com/runebookai/tome.git
synced 2025-07-21 00:27:30 +03:00
Merge branch 'main' into tasks
This commit is contained in:
55
CHANGELOG.md
55
CHANGELOG.md
@@ -1,5 +1,60 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.1 - 2025-06-27
|
||||
|
||||
### Fixes
|
||||
|
||||
- Reverted MSI removal – it's causing the Windows build to fail. Unsure why.
|
||||
|
||||
## 0.8.0 - 2025-06-27
|
||||
|
||||
### New Features
|
||||
|
||||
- Ability to rename MCP servers
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved error handling a bit, when adding MCP servers
|
||||
|
||||
### Misc
|
||||
|
||||
- Removed MSI Windows installer
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you previously installed on Windows via the MSI installer, you MUST
|
||||
> reinstall using the `.exe`
|
||||
|
||||
## 0.7.0 - 2025-06-13
|
||||
|
||||
### New Features
|
||||
|
||||
- Customizable System Prompt (in settings)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Correctly handle shell arguments with quotes
|
||||
|
||||
### Refactors
|
||||
|
||||
- Refactored models to be sane. They're now just instances of classes. No more
|
||||
"static everything".
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixes false negetive issue when validating Engines
|
||||
|
||||
## 0.6.2 - 2025-06-03
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixes false negetive issue when validating Engines
|
||||
|
||||
## 0.6.1 - 2025-06-03
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed issue causing infinite errors when Ollama not configured
|
||||
|
||||
## 0.6.0 - 2025-05-30
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
2
src-tauri/Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "Tome"
|
||||
version = "0.6.0"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
58
src/app.css
58
src/app.css
@@ -55,13 +55,65 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #0b0b0b;
|
||||
background: var(--background-color-dark);
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 28px;
|
||||
color: #ffffff;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--color-purple-dark: #7c5fc0;
|
||||
--color-purple: #7c5fc0;
|
||||
--color-red: #d7263d;
|
||||
--color-green: #4caf50;
|
||||
--color-yellow: #ffd600;
|
||||
|
||||
--border-color-light: #e0e0e0;
|
||||
--border-color-medium: #f5f5f5;
|
||||
--border-color-dark: #ffffff;
|
||||
|
||||
--background-color-light: #ffffff;
|
||||
--background-color-medium: #f5f5f5;
|
||||
--background-color-dark: #e0e0e0;
|
||||
|
||||
--text-color-light: #222222;
|
||||
--text-color-medium: #666666;
|
||||
--text-color-dark: #bbbbbb;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--color-purple-dark: #9d7cd8;
|
||||
--color-purple: #bb9af7;
|
||||
--color-red: #ff757f;
|
||||
--color-green: #c3e88d;
|
||||
--color-yellow: #ffc777;
|
||||
|
||||
--border-color-light: #191919;
|
||||
--border-color-medium: #0c0c0c;
|
||||
--border-color-dark: #0b0b0b;
|
||||
|
||||
--background-color-light: #191919;
|
||||
--background-color-medium: #0c0c0c;
|
||||
--background-color-dark: #0b0b0b;
|
||||
|
||||
--text-color-light: rgba(255, 255, 255, 0.75);
|
||||
--text-color-medium: #666666;
|
||||
--text-color-dark: #333333;
|
||||
}
|
||||
|
||||
html[data-theme="light"],
|
||||
body[data-theme="light"] {
|
||||
background: var(--background-color-medium);
|
||||
color: #222;
|
||||
}
|
||||
|
||||
html[data-theme="dark"],
|
||||
body[data-theme="dark"] {
|
||||
background: #0b0b0b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #ffffff33;
|
||||
color: var(--text-color-medium);
|
||||
}
|
||||
|
||||
8
src/app.d.ts
vendored
8
src/app.d.ts
vendored
@@ -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' {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)}
|
||||
>
|
||||
|
||||
112
src/components/Settings/CustomPrompt.svelte
Normal file
112
src/components/Settings/CustomPrompt.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
55
src/components/Textarea.svelte
Normal file
55
src/components/Textarea.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
18
src/lib/mcp.d.ts
vendored
@@ -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[];
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
131
src/lib/models/app.svelte.ts
Normal file
131
src/lib/models/app.svelte.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
40
src/lib/models/bare.svelte.ts
Normal file
40
src/lib/models/bare.svelte.ts
Normal 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];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
89
src/lib/models/config.svelte.ts
Normal file
89
src/lib/models/config.svelte.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
109
src/lib/models/engine.svelte.ts
Normal file
109
src/lib/models/engine.svelte.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
142
src/lib/models/mcp-server.svelte.ts
Normal file
142
src/lib/models/mcp-server.svelte.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
89
src/lib/models/message.svelte.ts
Normal file
89
src/lib/models/message.svelte.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
189
src/lib/models/session.svelte.ts
Normal file
189
src/lib/models/session.svelte.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
91
src/lib/models/setting.svelte.ts
Normal file
91
src/lib/models/setting.svelte.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
5
src/lib/shellwords.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from 'shellwords';
|
||||
|
||||
export function join(args: string[]): string {
|
||||
return args.map(arg => (arg.includes(' ') ? `"${arg}"` : arg)).join(' ');
|
||||
}
|
||||
@@ -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
7
src/lib/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface CheckboxEvent extends Event {
|
||||
currentTarget: EventTarget & HTMLInputElement;
|
||||
}
|
||||
|
||||
export interface ButtonEvent extends MouseEvent {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"inlineSourceMap": true,
|
||||
|
||||
Reference in New Issue
Block a user