🎉 Initial Commit

This commit is contained in:
Matte Noble
2025-04-25 14:42:36 -07:00
commit 65ae349558
118 changed files with 28931 additions and 0 deletions

71
.github/workflows/nightly.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: nightly
on:
schedule:
- cron: "0 0 * * *"
jobs:
dmg:
environment: ci
runs-on: macos-latest
permissions:
id-token: write
packages: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: pnpm
run: npm install -g pnpm
- name: dependencies
run: pnpm install
- name: keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
- name: verify-certificate
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported."
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: tome-nightly
releaseName: "Tome Nightly"
releaseBody: "See assets to download this version"
releaseDraft: true
prerelease: true
args: "--target aarch64-apple-darwin"

72
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: release
on:
push:
branches:
- release
jobs:
dmg:
environment: ci
runs-on: macos-latest
permissions:
id-token: write
packages: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: pnpm
run: npm install -g pnpm
- name: dependencies
run: pnpm install
- name: keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
- name: verify-certificate
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported."
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: v__VERSION__
releaseName: "Tome v__VERSION__"
releaseBody: "See assets to download this version"
releaseDraft: false
prerelease: false
args: "--target aarch64-apple-darwin"

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Misc
.rust-analyzer

9
.ignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.svelte-kit
build
node_modules
.DS_Store
.npmrc
src-tauri/.rust-analyzer
src-tauri/gen
src-tauri/target

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
src-tauri

20
.prettierrc Normal file
View File

@@ -0,0 +1,20 @@
{
"useTabs": true,
"singleQuote": true,
"tabWidth": 4,
"semi": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

133
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,133 @@
<img src="static/images/repo-header.png" alt="Tome" />
# Contributing to Tome
This document is a high level explanation of the architecture and codebase. It
won't cover everything right now, but will evolve and expand over time.
# Architecture
Tome is a [Tauri](https://v2.tauri.app/) app which means the system/desktop
side is written in Rust, while the front-end is a static Typescript application.
Specifically, a SvelteKit application.
## Frontend
The core of the domain logic lives in the frontend. It's a staticly generated
SvelteKit application. It's a little different than one you'd build for the web,
since we're in a singleton environment inside of a Tauri application. This
mostly effects our data layer, which the [Models](#models) section goes more
into.
At a glance:
- SvelteKit written in Typescript, built with Vite.
- Tailwind for CSS
**COMPONENTS** are isolated UI elements. They shouldn't hold any domain logic
and should, but in some cases may update models directly.
**ROUTES**
are "pages" or "screens". These should handle most of the orchestration
logic. With [global reactivity](#global-reactivity) (described below) there
really isn't a need for the Page Load functions you'd normally use in Svelte. In
rare occasions you may need to run an async function on page load this is when
a `+page.ts` could be handy.
**LIB**
If it's not orchestration logic, and it's not a UI component, it lives in
`lib`. This is where most of the supporting domain logic resides.
### Database
For all intents and purposes, the database and models are managed in the frontend.
It's done via a Tauri plugin called `tauri-plugin-sql`. Technically, the plugin
just creates a bridge between the frontend and backend through Tauri's `invoke`
mechanism. So while the queries are technically being made via Sqlx in Rust, as
a developers it's all done via our Typescript models.
### Models
Models are a big a strange in this application, due to Svelte's reactivity
rules. Models are classes with _ONLY_ `static` functions. Svelte's reactivity
doesn't support class instances so we need to always be passing around plain-old
JS objects (which do work with reactivity).
> [!IMPORTANT]
> Because of this, every model function needs to accept the object as it's first
> argument.
```ts
static async function name(session: ISession): string {
return session.other.thing.complicated.path.name;
}
```
You can change, reassign, etc. the `session` in this example and Svelte will
handle state and reactivity appropriately since we're always working with plain
objects. This enabled the [global reactivity](#global-reactivity) explained below.
### Model Interfaces
Declaration merging with classes in Typescript is annoying if all you want is
a plain JS object in the end (ie. _not_ the functions, etc.).
Instead, we create an `I<model>` interface for each model. This is the interface
we use throughout the application. So the `Session` model has an accompanying
`ISession` interface, for example.
### Global Reactivity
Each model has an underlying `repo`, which is a Svelte `$state()` object,
meaning it, and it's contents, are deeply reactive.
The base model logic handles updating the objects within and keeps them up to
date with persisted database changes. When the app loads, we sync all models
with their database table, then when you create, update, or delete, a record
from the database, we replicate the operation in the `repo`.
What this allows us to do is derive our model "instances" in routes and
components, from a reactive set of objects that will update in real-time, when
changes to them are persisted. The UI will automatically update since we're
always working with the same set of reactive objects.
You don't need to manage state manually. Just update a model like you're
updating a database record and the UI will react appropriately.
> [!IMPORTANT]
> _ALWAYS_ work with models via `const model = $derived(Model.all())` and _NEVER_ `$state()`.
The beauty of working with a singleton static application if that you can have
top-level objects like `repo` to track things and not have to worry about
isolation between requests, users, etc.
## Backend
The backend is a Tauri application written in Rust. It's mostly responsible for
anything we can't do from the frontend. For example, process management, MCP
server communication, etc. It's a relatively thin layer, but is the backbone of
everything in Tome.
At a glance:
- Tauri application written in Rust
- Database connectivity via `tauri-plugin-sql`
### Commands
We use commands like you would controllers in an MVC web framework. They're the
entrypoint for the frontend to accomplish something only the backend can do.
With that, they should be extremely concise. Often, if not always, calling a
single function in another module where the logic actually lives
### npx/uvx
Tome uses a project from the CashApp folks, called [Hermit](https://github.com/cashapp/hermit),
which "manages isolated, self-bootstrapping sets of tools in software projects."
`uvx` and `npx` are two pass-through scripts bundled with the app. Each one
first checks if we've previously installed the corresponding command and if not,
uses Hermit to do so, then runs the original command.
This is how we run MCP servers.

52
LICENSE Normal file
View File

@@ -0,0 +1,52 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
a. You must give any other recipients of the Work or Derivative Works a copy of this License; and
b. You must cause any modified files to carry prominent notices stating that You changed the files; and
c. You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
d. If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
<img src="static/images/repo-header.png" alt="Tome" />
<p align="center">
<code>A magical tool for using local LLMs with MCP servers</code>
</p>
<img src="static/images/screenshot.png" alt="Tome Screenshot" />
---
# Introducing Tome
Tome is a MacOS app (Windows and Linux support coming soon) designed for working with local LLMs and MCP servers, built by the team at [Runebook](https://runebook.ai). Tome manages your MCP servers so there's no fiddling with uv/npm or json files - connect it to Ollama, copy/paste some MCP servers, and chat with an MCP-powered model in seconds.
This is our very first Technical Preview so bear in mind things will be rough around the edges. Since the world of MCP servers and local models is ever-shifting (read: very janky), we recommend [joining us on Discord](https://discord.gg/3e7YP8MR) to share tips, tricks, and issues you run into. Also make sure to star this repo on GitHub to stay on top of updates and feature releases.
# Getting Started
## Requirements
- MacOS (Sequoia 15.0 or higher recommended)
- [Ollama](https://ollama.com/) (Either local or remote, you can configure any Ollama URL in settings)
- [Download the latest release of Tome](#)
## Quickstart
We'll be updating our [home page](https://runebook.ai) in the coming weeks with docs and an end-to-end tutorial, here's a quick getting started guide in the meantime.
1. Install [Tome](#) and [Ollama](https://ollama.com)
2. Install a [Tool supported model](https://ollama.com/search?c=tools) (we're partial to Qwen2.5, either 14B or 7B depending on your RAM)
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.
# Vision
We want to make local LLMs and MCP accessible to everyone. We're building a tool that allows you to be creative with LLMs, regardless
of whether you're an engineer, tinkerer, hobbyist, or anyone in between.
## Core Principles
- **Tome is local first:** You are in control of where your data goes.
- **Tome is for everyone:** You shouldn't have to manage programming languages, package managers, or json config files.
## What's Next
- Model support: Currently Tome uses Ollama for model management but we'd like to expand support for other LLM engines and possibly even cloud models, let us know if you have any requests.
- Operating system support: We're planning on adding support for Windows, followed by Linux.
- App builder: we believe long term that the best experiences will not be in a chat interface. We have plans to add additional tools that will enable you to create powerful applications and workflows.
- ??? Let us know what you'd like to see! Join our community via the links below, we'd love to hear from you.
# Community
[Discord](https://discord.gg/3e7YP8MR) [Bluesky](https://bsky.app/profile/runebook.ai) [Twitter](https://twitter.com/runebookai)

100
eslint.config.js Normal file
View File

@@ -0,0 +1,100 @@
import js from '@eslint/js';
import imports from 'eslint-plugin-import';
import sort from 'eslint-plugin-simple-import-sort';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
export default ts.config(
{
ignores: [
//'src-tauri',
'build',
'.svelte-kit',
'node_modules',
'static',
],
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
plugins: {
'simple-import-sort': sort,
'import': imports,
},
languageOptions: {
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
},
},
rules: {
'simple-import-sort/imports': [
'warn',
{
groups: [
['^\\u0000'],
['^node:'],
// Move `$app/*` into the "external" group
['^@?\\w', '^\\$app'],
['^\\.'],
// Consts first
['^\\$lib/const', '^'],
],
}
],
'simple-import-sort/exports': 'warn',
'import/first': 'warn',
}
},
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
},
},
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
// See more details at: https://typescript-eslint.io/packages/parser/
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'], // Add support for additional file extensions, such as .svelte
parser: ts.parser,
// Specify a parser for each language, if needed:
// parser: {
// ts: ts.parser,
// js: espree, // Use espree for .js files (add: import espree from 'espree')
// typescript: ts.parser
// },
// We recommend importing and specifying svelte.config.js.
// By doing so, some rules in eslint-plugin-svelte will automatically read the configuration and adjust their behavior accordingly.
// While certain Svelte settings may be statically loaded from svelte.config.js even if you dont specify it,
// explicitly specifying it ensures better compatibility and functionality.
svelteConfig
}
}
},
{
rules: {
'svelte/no-at-html-tags': 'off',
'@typescript-eslint/no-unused-imports': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
'vars': 'all',
'varsIgnorePattern': '^_',
'args': 'after-used',
'argsIgnorePattern': '^_'
}
]
}
}
);

62
package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "tome",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/typography": "^0.5.14",
"@tailwindcss/vite": "^4.0.9",
"@tauri-apps/cli": "^2.3.1",
"@tauri-apps/plugin-log": "^2.2.3",
"@types/uuid4": "^2.0.3",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.12",
"typescript": "^5.0.0",
"typescript-eslint": "^8.26.0",
"vite": "^6.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"dependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-fs": "^2.2.1",
"@tauri-apps/plugin-opener": "~2.2.6",
"@tauri-apps/plugin-sql": "~2",
"change-case": "^5.4.4",
"marked": "^15.0.7",
"moment": "^2.30.1",
"tailwind-merge": "^3.0.2"
}
}

4008
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
postcss.config.cjs Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
}
}

4
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
migrations/002_seed.sql

1
src-tauri/.taurignore Normal file
View File

@@ -0,0 +1 @@
.rust-analyzer

6615
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

47
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,47 @@
[package]
name = "Tome"
version = "0.1.0"
description = "The easiest way to work with local models and MCP servers."
authors = ["Runebook"]
license = "MIT"
repository = ""
default-run = "Tome"
edition = "2021"
rust-version = "1.85"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2", features = ["unstable", "macos-private-api"] }
anyhow = "1.0.97"
dotenv = "0.15.0"
markdown = "0.3.0"
chrono = { version = "0.4.31", features = ["serde"] }
log = "0.4.26"
log-panics = { version = "2", features = ["with-backtrace"] }
futures = "0.3.31"
rmcp = { version = "0.1.5", features = [
"client",
"transport-io",
"transport-child-process",
] }
tokio = "1.29.1"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
sysinfo = { version = "0.34.2", features = ["windows"] }
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
tauri-plugin-deep-link = "2"
tauri-plugin-log = "2"
tauri-plugin-fs = "2.2.1"
pin-project-lite = "0.2.16"
tauri-plugin-opener = "2"
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.26.0"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>

10
src-tauri/Info.plist Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Capture voice to interact with apps</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Capture voice to interface with apps</string>
</dict>
</plist>

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
src-tauri/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

66
src-tauri/npx Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# This product includes software developed at Square, Inc.
#
# The Initial Developer of some parts of the framework, which are copied
# from, derived from, or inspired by Hermit, is Square, Inc. (https://squareup.com).
# Copyright 2021 Square, Inc. All Rights Reserved.
#
# The Initial Developer of Hermit is Square, Inc. (http://www.squareup.com).
# Copyright 2021 Square, Inc. All Rights Reserved.
set -euo pipefail
LOGFILE="/tmp/runebook_npx.log"
> "$LOGFILE"
log() {
local LINE="$1"
echo "$(date +'%Y-%m-%d %H:%M:%S') :: $LINE" >> $LOGFILE
}
trap 'log "ERROR: Exiting w/ status: $?."' ERR
log "Starting..."
log "mkdir -p ~/.config/runebook/hermit/bin ?"
mkdir -p ~/.config/runebook/hermit/bin
log "cd ~/.config/runebook/hermit"
cd ~/.config/runebook/hermit
if [ ! -f ~/.config/runebook/hermit/bin/hermit ]; then
log "Downloading hermit..."
curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s \
| tr '[:upper:]' '[:lower:]')-$(uname -m \
| sed 's/x86_64/amd64/' \
| sed 's/aarch64/arm64/').gz" \
| gzip -dc > ~/.config/runebook/hermit/bin/hermit
log "Downloaded hermit..."
log "chmod +x ~/.config/runebook/hermit/bin/hermit"
chmod +x ~/.config/runebook/hermit/bin/hermit
else
log "Found existing hermit..."
fi
log "mkdir -p ~/.config/runebook/hermit/cache"
mkdir -p ~/.config/runebook/hermit/cache
log "export HERMIT_STATE_DIR=~/.config/runebook/hermit/cache"
export HERMIT_STATE_DIR=~/.config/runebook/hermit/cache
log "export PATH=~/.config/runebook/hermit/bin:$PATH"
export PATH=~/.config/runebook/hermit/bin:$PATH
log "hermit init"
hermit init >> "$LOGFILE"
log "chmod +x hermit"
chmod +x ~/.config/runebook/hermit/bin/hermit
log "hermit install node >> "$LOGFILE""
hermit install node >> "$LOGFILE"
log "npx $*"
npx "$@" || log "Error running npx $*"
log "End."

60
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,60 @@
use rmcp::model::Tool;
use tauri::AppHandle;
use crate::mcp;
use crate::State;
macro_rules! ok_or_err {
($expr:expr) => {
match $expr {
Ok(e) => Ok(e),
Err(_) => Err(()),
}
};
}
#[tauri::command]
pub async fn get_metadata(command: String, app: AppHandle) -> Result<String, ()> {
ok_or_err!(mcp::peer_info(command, app).await)
}
#[tauri::command]
pub async fn start_mcp_server(session_id: i32, command: String, app: AppHandle) -> Result<(), ()> {
println!("-> start_mcp_server({}, {})", session_id, command);
ok_or_err!(mcp::start(session_id, command, app).await)
}
#[tauri::command]
pub async fn stop_mcp_server(
session_id: i32,
name: String,
state: tauri::State<'_, State>,
) -> Result<(), ()> {
println!("-> stop_mcp_server({}, {})", session_id, name);
ok_or_err!(mcp::stop(session_id, name, state).await)
}
#[tauri::command]
pub async fn get_mcp_tools(
session_id: i32,
state: tauri::State<'_, State>,
) -> Result<Vec<Tool>, ()> {
ok_or_err!(mcp::get_tools(session_id, state).await)
}
#[tauri::command]
pub async fn call_mcp_tool(
session_id: i32,
name: String,
arguments: serde_json::Map<String, serde_json::Value>,
state: tauri::State<'_, State>,
) -> Result<String, ()> {
Ok(mcp::call_tool(session_id, name, arguments, state)
.await
.unwrap())
}
#[tauri::command]
pub async fn stop_session(session_id: i32, state: tauri::State<'_, State>) -> Result<(), ()> {
ok_or_err!(mcp::stop_session(session_id, state).await)
}

85
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,85 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#![warn(unused_extern_crates)]
mod commands;
mod mcp;
mod migrations;
mod process;
mod state;
mod window;
use std::env;
use process::Process;
use tauri::{Manager, RunEvent};
use tauri_plugin_deep_link::DeepLinkExt;
use crate::migrations::migrations;
use crate::state::State;
use crate::window::configure_window;
#[cfg(target_os = "macos")]
use crate::window::macos::configure_macos_window;
fn main() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {}))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
.plugin(
tauri_plugin_sql::Builder::new()
.add_migrations("sqlite:tome.db", migrations())
.build(),
)
.plugin(
tauri_plugin_log::Builder::new()
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::LogDir {
file_name: Some("log".to_string()),
},
))
.level(log::LevelFilter::Debug)
.build(),
)
.setup(|app| {
let window = app.get_window("main").expect("Couldn't get main window");
log_panics::init();
app.manage(State {
sessions: Default::default(),
});
configure_window(&window);
#[cfg(target_os = "macos")]
configure_macos_window(&window);
app.deep_link().on_open_url(|event| {
log::info!("deep link URLs: {:?}", event.urls());
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
// MCP
commands::get_metadata,
commands::get_mcp_tools,
commands::call_mcp_tool,
commands::start_mcp_server,
commands::stop_mcp_server,
// Sessions
commands::stop_session,
])
.build(tauri::generate_context!())
.expect("error running Tome");
app.run(|_, event| {
if let RunEvent::Exit = event {
// Ensure we kill every child (and child of child, of child, etc.)
// MCP server process
Process::current().kill().unwrap();
}
});
}

119
src-tauri/src/mcp.rs Normal file
View File

@@ -0,0 +1,119 @@
pub(crate) mod process;
pub(crate) mod server;
use crate::state::State;
use anyhow::Result;
use rmcp::model::CallToolRequestParam;
use rmcp::model::Tool;
use server::McpServer;
use tauri::{AppHandle, Manager};
// Start an MCP Server
//
// # Process Management Shenanigans
//
// Most mcp commands use either `npx` or `uvx`. Those commands end up just spawning _another_
// process, which is the actual mcp server.
//
// Since children of child processes DO NOT get killed, when you kill the child, we need to
// manually do that. Otherwise we end up with a bunch of zombie, detached, mcp servers.
//
// To deal with this, we collect child pids before we launch the server and child pids after it's
// launched. We track those, then explicitly kill them all when in `stop`.
//
pub async fn start(session_id: i32, command: String, app: AppHandle) -> Result<()> {
let handle = app.clone();
let state = handle.state::<State>();
let server = McpServer::start(command, app).await?;
let mut sessions = state.sessions.lock().await;
let mut session = sessions.remove(&session_id).unwrap_or_default();
// Server already running. Kill the one we just spun up. It's a little weird to start the
// process then immediately kill it, but we need it running to get the name :/
//
// Makes the `start_mcp_server` command idempotent.
//
if session.mcp_servers.contains_key(&server.name()) {
server.kill()?;
sessions.insert(session_id, session);
return Ok(());
}
server.tools().await?.iter().for_each(|tool| {
session.tools.insert(tool.name.to_string(), server.name());
});
session.mcp_servers.insert(server.name(), server);
sessions.insert(session_id, session);
Ok(())
}
pub async fn stop(session_id: i32, name: String, state: tauri::State<'_, State>) -> Result<()> {
let mut sessions = state.sessions.lock().await;
if let Some(mut session) = sessions.remove(&session_id) {
if let Some(server) = session.mcp_servers.remove(&name) {
server.kill()?;
}
sessions.insert(session_id, session);
}
Ok(())
}
pub async fn stop_session(session_id: i32, state: tauri::State<'_, State>) -> Result<()> {
let mut sessions = state.sessions.lock().await;
if let Some(session) = sessions.remove(&session_id) {
for server in session.mcp_servers.values() {
server.kill()?;
}
}
Ok(())
}
pub async fn get_tools(session_id: i32, state: tauri::State<'_, State>) -> Result<Vec<Tool>> {
let mut tools: Vec<Tool> = vec![];
let sessions = state.sessions.lock().await;
let running_session = match sessions.get(&session_id) {
Some(s) => s,
None => return Ok(vec![]),
};
for server in running_session.mcp_servers.values() {
tools.extend(server.tools().await?)
}
Ok(tools)
}
pub async fn call_tool(
session_id: i32,
name: String,
arguments: serde_json::Map<String, serde_json::Value>,
state: tauri::State<'_, State>,
) -> Result<String> {
let sessions = state.sessions.lock().await;
let running_session = sessions.get(&session_id).unwrap();
let service_name = running_session.tools.get(&name).unwrap().clone();
let server = running_session.mcp_servers.get(&service_name).unwrap();
let tool_call = CallToolRequestParam {
name: std::borrow::Cow::from(name),
arguments: Some(arguments),
};
server.call_tool(tool_call).await
}
pub async fn peer_info(command: String, app: AppHandle) -> Result<String> {
let server = McpServer::start(command, app).await?;
let peer_info = server.peer_info();
server.kill()?;
Ok(serde_json::to_string(&peer_info)?)
}

View File

@@ -0,0 +1,98 @@
use anyhow::{anyhow, Result};
use rmcp::service::ServiceRole;
use rmcp::transport::IntoTransport;
use sysinfo::Pid;
use tauri::path::BaseDirectory;
use tauri::{AppHandle, Manager};
use tokio::io::AsyncRead;
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
#[derive(Debug)]
pub(crate) struct McpProcess {
pub child: Child,
pub child_stdin: ChildStdin,
pub child_stdout: ChildStdout,
}
impl McpProcess {
pub fn start(command: String, app: AppHandle) -> Result<Self> {
let args: Vec<&str> = command.split(" ").collect();
let main = *args.first().unwrap();
let args = args.clone().drain(1..).collect::<Vec<&str>>();
let main = match main {
"uvx" => app.path().resolve("uvx", BaseDirectory::Resource)?,
"npx" => app.path().resolve("npx", BaseDirectory::Resource)?,
s => return Err(anyhow!("{} servers not supported", s)),
};
let mut cmd = Command::new(main);
let cmd = cmd.args(args);
cmd.kill_on_drop(true)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped());
let mut child = cmd.spawn()?;
let child_stdin = child.stdin.take().unwrap();
let child_stdout = child.stdout.take().unwrap();
Ok(Self {
child,
child_stdin,
child_stdout,
})
}
pub fn pid(&self) -> Pid {
Pid::from_u32(self.child.id().unwrap())
}
pub fn split(self) -> (McpProcessOut, ChildStdin) {
let McpProcess {
child,
child_stdin,
child_stdout,
} = self;
(
McpProcessOut {
child,
child_stdout,
},
child_stdin,
)
}
}
pin_project_lite::pin_project! {
pub(crate) struct McpProcessOut {
child: Child,
#[pin]
child_stdout: ChildStdout,
}
}
impl AsyncRead for McpProcessOut {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
self.project().child_stdout.poll_read(cx, buf)
}
}
impl<R: ServiceRole> IntoTransport<R, std::io::Error, ()> for McpProcess {
fn into_transport(
self,
) -> (
impl futures::Sink<rmcp::service::TxJsonRpcMessage<R>, Error = std::io::Error> + Send + 'static,
impl futures::Stream<Item = rmcp::service::RxJsonRpcMessage<R>> + Send + 'static,
) {
IntoTransport::<R, std::io::Error, rmcp::transport::io::TransportAdapterAsyncRW>::into_transport(
self.split(),
)
}
}

View File

@@ -0,0 +1,62 @@
use crate::process::Process;
use anyhow::Result;
use rmcp::model::{CallToolRequestParam, RawContent, Tool};
use rmcp::service::ServiceRole;
use rmcp::ServiceExt;
use rmcp::{service::RunningService, RoleClient};
use sysinfo::Pid;
use tauri::AppHandle;
use super::process::McpProcess;
type Service = RunningService<RoleClient, ()>;
#[derive(Debug)]
pub struct McpServer {
service: Service,
pid: Pid,
}
impl McpServer {
pub async fn start(command: String, app: AppHandle) -> Result<Self> {
let proc = McpProcess::start(command, app)?;
let pid = proc.pid();
let service = ().serve(proc).await?;
Ok(Self { service, pid })
}
pub fn name(&self) -> String {
self.peer_info().server_info.name
}
pub fn peer_info(&self) -> <RoleClient as ServiceRole>::PeerInfo {
self.service.peer_info().clone()
}
pub async fn tools(&self) -> Result<Vec<Tool>> {
Ok(self.service.list_all_tools().await?)
}
pub async fn call_tool(&self, request: CallToolRequestParam) -> Result<String> {
Ok(self
.service
.call_tool(request)
.await?
.content
.iter()
.map(|r| match r.raw.clone() {
RawContent::Text(t) => t.text,
_ => String::new(),
})
.collect::<Vec<String>>()
.join("\n"))
}
pub fn kill(&self) -> Result<bool> {
match Process::find(self.pid) {
Some(p) => p.kill(),
None => Ok(false),
}
}
}

109
src-tauri/src/migrations.rs Normal file
View File

@@ -0,0 +1,109 @@
use tauri_plugin_sql::{Migration, MigrationKind};
pub fn migrations() -> Vec<Migration> {
vec![
Migration {
version: 1,
description: "bootstrap",
sql: r#"
CREATE TABLE IF NOT EXISTS apps (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT "Untitled",
description TEXT NOT NULL DEFAULT "",
readme TEXT NOT NULL DEFAULT "",
image TEXT NOT NULL DEFAULT "",
interface TEXT NOT NULL DEFAULT "chat",
nodes JSON NOT NULL DEFAULT "[]",
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
app_id INTEGER NOT NULL,
nodes JSON NOT NULL DEFAULT "[]",
summary TEXT NOT NULL DEFAULT "Unknown",
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(app_id) REFERENCES apps(id)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL,
name TEXT,
tool_calls JSON NOT NULL DEFAULT "[]",
session_id INTEGER NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS mcp_servers (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
command TEXT NOT NULL,
metadata JSON
);
CREATE TABLE IF NOT EXISTS apps_mcp_servers (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
app_id INTEGER NOT NULL,
mcp_server_id INTEGER NOT NULL,
UNIQUE(app_id, mcp_server_id),
FOREIGN KEY(app_id) REFERENCES apps(id),
FOREIGN KEY(mcp_server_id) REFERENCES mcp_servers(id)
);
"#,
kind: MigrationKind::Up,
},
Migration {
version: 2,
description: "app_seeds",
sql: r#"
INSERT INTO
"apps" ("name", "description", "readme", "image", "interface", "nodes")
VALUES
('Chat', 'Chat with an LLM', '', '', 'Chat', '[]');
"#,
kind: MigrationKind::Up,
},
Migration {
version: 3,
description: "add_settings",
sql: r#"
CREATE TABLE IF NOT EXISTS settings (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
display TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
UNIQUE(key)
);
INSERT INTO
"settings" ("display", "key", "value")
VALUES
('Ollama URL', 'ollama-url', '"http://localhost:11434"')
;
"#,
kind: MigrationKind::Up,
},
Migration {
version: 4,
description: "add_session_config",
sql: r#"
ALTER TABLE sessions ADD COLUMN config JSON NOT NULL DEFAULT "{}";
"#,
kind: MigrationKind::Up,
},
Migration {
version: 5,
description: "add_message_thoughts_and_model",
sql: r#"
ALTER TABLE messages ADD COLUMN thought TEXT;
ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT "";
"#,
kind: MigrationKind::Up,
},
]
}

55
src-tauri/src/process.rs Normal file
View File

@@ -0,0 +1,55 @@
use std::collections::HashSet;
use anyhow::Result;
use sysinfo::{Pid, System};
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
pub struct Process {
pub pid: Pid,
}
impl Process {
pub fn current() -> Self {
Self {
pid: sysinfo::get_current_pid().unwrap(),
}
}
pub fn find(pid: Pid) -> Option<Self> {
System::new_all().process(pid).map(|_| Self { pid })
}
pub fn children(&self) -> Result<HashSet<Self>> {
let mut procs: HashSet<Self> = HashSet::new();
let sys = System::new_all();
sys.processes().iter().for_each(|(pid, proc)| {
if let Some(parent_pid) = proc.parent() {
if parent_pid == self.pid {
let proc = Self { pid: *pid };
procs.insert(proc.clone());
procs.extend(proc.children().unwrap());
}
}
});
Ok(procs)
}
pub fn kill(&self) -> Result<bool> {
let sys = System::new_all();
self.children()?
.iter()
.all(|proc| match sys.process(proc.pid) {
Some(p) => p.kill(),
None => false,
});
if let Some(parent) = sys.process(self.pid) {
Ok(parent.kill())
} else {
Ok(false)
}
}
}

20
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,20 @@
use std::collections::HashMap;
use crate::mcp::server::McpServer;
use tokio::sync::Mutex;
type SessionId = i32;
type McpServerName = String;
type ToolName = String;
#[derive(Debug, Default)]
pub struct RunningSession {
pub mcp_servers: HashMap<McpServerName, McpServer>,
pub tools: HashMap<ToolName, McpServerName>,
}
#[derive(Debug, Default)]
pub struct State {
pub sessions: Mutex<HashMap<SessionId, RunningSession>>,
}

20
src-tauri/src/window.rs Normal file
View File

@@ -0,0 +1,20 @@
#[cfg(target_os = "macos")]
pub(crate) mod macos;
use tauri::{PhysicalPosition, PhysicalSize, Window};
pub fn configure_window(window: &Window) {
if let Some(monitor) = window.current_monitor().unwrap() {
let size = monitor.size();
let width = size.width as f32;
let height = size.height as f32;
window
.set_size(PhysicalSize::new(width * 0.8, height * 0.8))
.expect("Couldn't resize window");
window
.set_position(PhysicalPosition::new(width * 0.1, height * 0.1))
.expect("Couldn't position window");
}
}

View File

@@ -0,0 +1,30 @@
use cocoa::appkit::{NSColor, NSWindow};
use cocoa::appkit::{NSWindowStyleMask, NSWindowTitleVisibility};
use cocoa::base::{id, nil};
use tauri::{TitleBarStyle, Window};
pub fn configure_macos_window(window: &Window) {
window
.set_title_bar_style(TitleBarStyle::Transparent)
.expect("Cannot set titlebar style to transparent");
unsafe {
let ns_window = window.ns_window().unwrap() as id;
let mut style_mask = ns_window.styleMask();
style_mask.set(NSWindowStyleMask::NSBorderlessWindowMask, true);
style_mask.set(NSWindowStyleMask::NSFullSizeContentViewWindowMask, true);
ns_window.setStyleMask_(style_mask);
ns_window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
ns_window.setTitlebarAppearsTransparent_(true);
ns_window.setBackgroundColor_(NSColor::colorWithRed_green_blue_alpha_(
nil,
11.0 / 255.0,
11.0 / 255.0,
11.0 / 255.0,
1.0,
));
}
}

107
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,107 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"build": {
"beforeBuildCommand": "pnpm run build",
"beforeDevCommand": "pnpm run dev",
"frontendDist": "../build",
"devUrl": "http://localhost:5173"
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"targets": "all",
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
},
"longDescription": "",
"macOS": {
"entitlements": "./Entitlements.plist",
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [
"uvx",
"npx"
],
"shortDescription": "",
"linux": {
"deb": {
"depends": []
}
}
},
"productName": "Tome",
"mainBinaryName": "tome",
"version": "0.1.0",
"identifier": "co.runebook",
"plugins": {
"sql": {
"preload": [
"sqlite:tome.db"
]
},
"deep-link": {
"desktop": {
"schemes": [
"tome"
]
}
}
},
"app": {
"macOSPrivateApi": true,
"windows": [
{
"fullscreen": false,
"height": 600,
"resizable": true,
"title": "",
"width": 800,
"useHttpsScheme": true,
"transparent": true
}
],
"security": {
"csp": null,
"capabilities": [
{
"identifier": "default",
"windows": [
"*"
],
"permissions": [
"core:default",
"opener:default",
"fs:allow-appdata-read",
"fs:allow-appdata-write",
"fs:allow-resource-read",
"sql:default",
"sql:allow-execute"
]
},
{
"identifier": "draggable-window",
"windows": [
"*"
],
"permissions": [
"core:window:allow-start-dragging"
]
}
]
}
}
}

69
src-tauri/uvx Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# This product includes software developed at Square, Inc.
#
# The Initial Developer of some parts of the framework, which are copied
# from, derived from, or inspired by Hermit, is Square, Inc. (https://squareup.com).
# Copyright 2021 Square, Inc. All Rights Reserved.
#
# The Initial Developer of Hermit is Square, Inc. (http://www.squareup.com).
# Copyright 2021 Square, Inc. All Rights Reserved.
set -euo pipefail
LOGFILE="/tmp/runebook_uvx.log"
> "$LOGFILE"
log() {
local LINE="$1"
echo "$(date +'%Y-%m-%d %H:%M:%S') :: $LINE" >> $LOGFILE
}
trap 'log "ERROR: Exiting w/ status: $?."' ERR
log "Starting..."
log "mkdir -p ~/.config/runebook/hermit/bin ?"
mkdir -p ~/.config/runebook/hermit/bin
log "cd ~/.config/runebook/hermit"
cd ~/.config/runebook/hermit
if [ ! -f ~/.config/runebook/hermit/bin/hermit ]; then
log "Downloading hermit..."
curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s \
| tr '[:upper:]' '[:lower:]')-$(uname -m \
| sed 's/x86_64/amd64/' \
| sed 's/aarch64/arm64/').gz" \
| gzip -dc > ~/.config/runebook/hermit/bin/hermit
log "Downloaded hermit..."
log "chmod +x ~/.config/runebook/hermit/bin/hermit"
chmod +x ~/.config/runebook/hermit/bin/hermit
else
log "Found existing hermit..."
fi
log "mkdir -p ~/.config/runebook/hermit/cache"
mkdir -p ~/.config/runebook/hermit/cache
log "export HERMIT_STATE_DIR=~/.config/runebook/hermit/cache"
export HERMIT_STATE_DIR=~/.config/runebook/hermit/cache
log "export PATH=~/.config/runebook/hermit/bin:$PATH"
export PATH=~/.config/runebook/hermit/bin:$PATH
log "hermit init"
hermit init >> "$LOGFILE"
log "chmod +x hermit"
chmod +x ~/.config/runebook/hermit/bin/hermit
log "hermit install python@3.12"
hermit install python3@3.12 >> "$LOGFILE"
log "hermit install uv"
hermit install uv >> "$LOGFILE"
log "uvx $*"
uvx "$@" || log "Error running uvx $*"
log "End."

62
src/app.css Normal file
View File

@@ -0,0 +1,62 @@
@import 'tailwindcss';
@import './markdown.css';
@config '../tailwind.config.cjs';
@theme {
--color-purple-dark: #9d7cd8;
--color-purple: #bb9af7;
--color-red: #ff757f;
--color-green: #c3e88d;
--border-color-light: #171717;
--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;
--height-titlebar: 60px;
--width-nav: 80px;
--height-content: calc(100% - var(--height-titlebar));
--width-content-minus-nav: calc(100dvw - var(--width-nav));
}
@font-face {
font-family: "Plus Jakarta Sans";
src: url("/fonts/PlusJakartaSans-VariableFont_wght.ttf");
}
@font-face {
font-family: "Plus Jakarta Sans";
font-style: italic;
src: url("/fonts/PlusJakartaSans-Italic-VariableFont_wght.ttf");
}
* {
/* Hide scrollbar */
-ms-overflow-style: none;
scrolbar-width: none;
}
/* Hide scrollbar */
*::-webkit-scrollbar {
display: none;
}
html,
body {
background: #0b0b0b;
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 14px;
line-height: 28px;
color: #FFFFFF;
}
input::placeholder {
color: #FFFFFF33;
}

40
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,40 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
// Utility type for plain, unknown-value, JS Objects
type Obj = { [key: string]: any }; // eslint-disable-line
interface ObjectConstructor {
compact<T>(o: Obj): T;
without<T>(o: Obj, keys: string[]): T;
remove<T>(o: Obj, key: string): T | undefined;
}
interface Array<T> {
sortBy(key: string): Array<T>;
}
interface CheckboxEvent extends Event {
currentTarget: EventTarget & HTMLInputElement;
}
interface SpeechRecognitionEvent {
results: SpeechRecognitionResult[];
}
}
declare module 'svelte/elements' {
interface HTMLTextareaAttributes {
autocorrect: "on" | "off";
}
}
export { };

15
src/app.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

12
src/components/Box.svelte Normal file
View File

@@ -0,0 +1,12 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Flex from '$components/Flex.svelte';
const { children, class: cls = '', ...rest }: SvelteHTMLElements['div'] = $props();
</script>
<Flex class={twMerge('border-light mb-2 rounded-lg border p-4', cls?.toString())} {...rest}>
{@render children?.()}
</Flex>

195
src/components/Chat.svelte Normal file
View File

@@ -0,0 +1,195 @@
<script lang="ts">
import { onMount } from 'svelte';
import Flex from '$components/Flex.svelte';
import Message from '$components/Message.svelte';
import { dispatch } from '$lib/dispatch';
import type { Role } from '$lib/llm';
import type { IMessage } from '$lib/models/message';
import Session, { type ISession } from '$lib/models/session';
interface Props {
session: ISession;
model: string;
onMessages?: (message: IMessage[]) => Promise<void>;
}
const { session, model, onMessages }: Props = $props();
// DOM elements used via `bind:this`
let input: HTMLTextAreaElement;
let content: HTMLDivElement;
// Full history of chat messages in this session
let messages: IMessage[] = $state([]);
// Is the LLM processing (when true, we show the ellipsis)
let loading = $state(false);
// Scroll the chat history box to the bottom
//
function scrollToBottom(_: HTMLElement) {
// Scroll to the maximum integer JS can handle, to ensure we're at the
// bottom. `scrollHeight` doesn't work here for some mysterious reason.
// ¯\_(ツ)_/¯
content.scroll({ top: 9e15 });
}
function resize() {
const min = 56; // Default height of the input
const scrollHeight = input.scrollHeight;
const padding = scrollHeight > min ? 6 : 0;
const scroll = Math.max(56, input.scrollHeight) + padding;
const height = /^\s*$/.test(input.value) ? '56px' : `${scroll}px`;
input.style.height = '0px';
input.style.height = height;
}
// Add a message directly to state.
//
// This will be overwritten by any calls to `reloadMessages`. It's mostly
// to add the User's first prompt without having to wait for the entire
// `dispatch` process to finish.
//
function addMessage(role: Role, content: string) {
messages.push({
role,
content,
model,
name: '',
toolCalls: [],
});
}
// Set the messages state
//
// Only sets messages that are from either the User or the model. Ignores
// tool call messages, system prompts, and any empty messages that got
// through somehow.
//
async function setMessages(_messages: IMessage[]) {
messages = _messages
.filter((m) => ['user', 'assistant'].includes(m.role))
.filter((m) => m.content !== '');
if (onMessages) {
await onMessages(messages);
}
}
// Reload all messages from the database
//
async function reloadMessages() {
await setMessages(Session.messages(session));
}
// When the User submits a message
//
async function onChatInput(e: KeyboardEvent) {
if (e.key == 'Enter') {
e.preventDefault();
await send();
return false;
}
}
// Dispatch a message to the LLM.
//
async function send() {
const content = input.value;
// Add the prompt to UI temporarily, until `dispatch` processes
// everything.
addMessage('user', content);
loading = true;
// Clear input
input.value = '';
resize();
// Send to LLM
await dispatch(session, model, content);
loading = false;
// Reload after LLM responses
await reloadMessages();
}
$effect(() => {
input.focus();
});
onMount(async () => {
resize();
await setMessages(Session.messages(session));
});
</script>
<Flex class="h-content w-full flex-col p-8 pb-0">
<!-- Chat Log -->
<div bind:this={content} class="bg-medium relative mb-8 h-full w-full overflow-auto px-2">
{#each messages as message (message.id)}
<Flex class="mb-8 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} />
</Flex>
{/each}
{#if loading}
<Flex class="border-light h-12 w-24 rounded-lg text-center">
<div id="loading" class="m-auto"></div>
</Flex>
{/if}
</div>
<!-- Input Box -->
<textarea
rows="1"
autocomplete="off"
autocorrect="off"
bind:this={input}
oninput={resize}
onkeydown={onChatInput}
placeholder="Message..."
class="disabled:text-dark item bg-dark border-light focus:border-purple/15
mb-8 h-auto w-full grow rounded-xl border p-3 pl-4 outline-0 transition duration-300"
></textarea>
</Flex>
<style>
#loading {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #fff;
box-shadow:
16px 0 #fff,
-16px 0 #fff;
position: relative;
animation: flash 1s ease-out infinite alternate;
}
@keyframes flash {
0% {
background-color: #fff2;
box-shadow:
16px 0 #fff2,
-16px 0 #fff;
}
50% {
background-color: #fff;
box-shadow:
16px 0 #fff2,
-16px 0 #fff2;
}
100% {
background-color: #fff2;
box-shadow:
16px 0 #fff,
-16px 0 #fff2;
}
}
</style>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
const { children, class: cls, ...rest }: SvelteHTMLElements['div'] = $props();
</script>
<div class={twMerge('flex flex-row items-center', cls?.toString())} {...rest}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,12 @@
<svg
viewBox="0 0 600 530"
width="100%"
height="100%"
version="1.1"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"
/>
</svg>

After

Width:  |  Height:  |  Size: 746 B

View File

@@ -0,0 +1,24 @@
<svg
enable-background="new 0 0 32 32"
height="100%"
viewBox="0 0 32 32"
width="100%"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<g id="_x31_4_comment">
<g>
<path
d="m8 30c-.147 0-.295-.033-.434-.099-.345-.167-.566-.516-.566-.901v-5h-3c-1.654 0-3-1.346-3-3v-16c0-1.654 1.346-3 3-3h24c1.654 0 3 1.346 3 3v16c0 1.654-1.346 3-3 3h-12.149l-7.226 5.781c-.181.145-.402.219-.625.219zm-4-26c-.552 0-1 .449-1 1v16c0 .551.448 1 1 1h4c.553 0 1 .447 1 1v3.92l5.875-4.701c.178-.142.397-.219.625-.219h12.5c.552 0 1-.449 1-1v-16c0-.551-.448-1-1-1z"
></path>
</g>
<g>
<path d="m24 12h-16c-.553 0-1-.448-1-1s.447-1 1-1h16c.553 0 1 .448 1 1s-.447 1-1 1z"
></path>
</g>
<g>
<path d="m16 16h-8c-.553 0-1-.448-1-1s.447-1 1-1h8c.553 0 1 .448 1 1s-.447 1-1 1z"
></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 946 B

View File

@@ -0,0 +1,11 @@
<svg
fill="currentColor"
viewBox="0 0 24 24"
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
><path
d="m2 12c0-1.8129.44444-3.48538 1.33333-5.01754.90059-1.53217 2.11696-2.74269 3.64913-3.63158 1.54386-.90059 3.21634-1.35088 5.01754-1.35088 1.8129 0 3.4854.45029 5.0175 1.35088 1.5322.88889 2.7427 2.10526 3.6316 3.64912.9006 1.53216 1.3509 3.1988 1.3509 5s-.4503 3.4737-1.3509 5.0175c-.8889 1.5322-2.1052 2.7486-3.6491 3.6492-1.5322.8889-3.1988 1.3333-5 1.3333s-3.47368-.4444-5.01754-1.3333c-1.53217-.9006-2.74854-2.117-3.64913-3.6492-.88889-1.5438-1.33333-3.2163-1.33333-5.0175zm4.50877 0c0 .2807.09942.5146.29825.7018l2.89473 2.8947c.22223.2105.49125.3158.80705.3158.3275 0 .5965-.1053.807-.3158l5.8947-5.89475c.1989-.17543.2983-.40935.2983-.70175 0-.2807-.0994-.51462-.2983-.70175-.1871-.19884-.421-.29825-.7017-.29825s-.5146.09941-.7018.29825l-5.2982 5.28065-2.29827-2.2807c-.17544-.1988-.40936-.2982-.70176-.2982-.269 0-.50292.0994-.70175.2982-.19883.1872-.29825.4211-.29825.7018z"
>
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,49 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 512 512"
style="enable-background:new 0 0 512 512;"
width="100%"
height="100%"
fill="currentColor"
xml:space="preserve"
>
<g>
<g>
<path
d="M436,60h-75V45c0-24.813-20.187-45-45-45H196c-24.813,0-45,20.187-45,45v15H76c-24.813,0-45,20.187-45,45
c0,19.928,13.025,36.861,31.005,42.761L88.76,470.736C90.687,493.875,110.385,512,133.604,512h244.792
c23.22,0,42.918-18.125,44.846-41.271l26.753-322.969C467.975,141.861,481,124.928,481,105C481,80.187,460.813,60,436,60z M181,45
c0-8.271,6.729-15,15-15h120c8.271,0,15,6.729,15,15v15H181V45z M393.344,468.246c-0.643,7.712-7.208,13.754-14.948,13.754
H133.604c-7.739,0-14.305-6.042-14.946-13.747L92.294,150h327.412L393.344,468.246z M436,120H76c-8.271,0-15-6.729-15-15
s6.729-15,15-15h360c8.271,0,15,6.729,15,15S444.271,120,436,120z"
></path>
</g>
</g>
<g>
<g>
<path
d="M195.971,436.071l-15-242c-0.513-8.269-7.67-14.558-15.899-14.043c-8.269,0.513-14.556,7.631-14.044,15.899l15,242.001
c0.493,7.953,7.097,14.072,14.957,14.072C189.672,452,196.504,444.684,195.971,436.071z"
></path>
</g>
</g>
<g>
<g>
<path
d="M256,180c-8.284,0-15,6.716-15,15v242c0,8.284,6.716,15,15,15s15-6.716,15-15V195C271,186.716,264.284,180,256,180z"
></path>
</g>
</g>
<g>
<g>
<path
d="M346.927,180.029c-8.25-0.513-15.387,5.774-15.899,14.043l-15,242c-0.511,8.268,5.776,15.386,14.044,15.899
c8.273,0.512,15.387-5.778,15.899-14.043l15-242C361.483,187.659,355.196,180.541,346.927,180.029z"
></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 24 24"
style="enable-background:new 0 0 24 24;"
fill="currentColor"
xml:space="preserve"
width="100%"
height="100%"
>
<g>
<path
d="M20.317,4.37c-1.53-0.702-3.17-1.219-4.885-1.515c-0.031-0.006-0.062,0.009-0.079,0.037 c-0.211,0.375-0.445,0.865-0.608,1.249c-1.845-0.276-3.68-0.276-5.487,0C9.095,3.748,8.852,3.267,8.641,2.892 C8.624,2.864,8.593,2.85,8.562,2.855C6.848,3.15,5.208,3.667,3.677,4.37C3.664,4.375,3.652,4.385,3.645,4.397 c-3.111,4.648-3.964,9.182-3.546,13.66c0.002,0.022,0.014,0.043,0.031,0.056c2.053,1.508,4.041,2.423,5.993,3.029 c0.031,0.01,0.064-0.002,0.084-0.028c0.462-0.63,0.873-1.295,1.226-1.994c0.021-0.041,0.001-0.09-0.042-0.106 c-0.653-0.248-1.274-0.55-1.872-0.892c-0.047-0.028-0.051-0.095-0.008-0.128c0.126-0.094,0.252-0.192,0.372-0.291 c0.022-0.018,0.052-0.022,0.078-0.01c3.928,1.793,8.18,1.793,12.061,0c0.026-0.012,0.056-0.009,0.079,0.01 c0.12,0.099,0.246,0.198,0.373,0.292c0.044,0.032,0.041,0.1-0.007,0.128c-0.598,0.349-1.219,0.645-1.873,0.891 c-0.043,0.016-0.061,0.066-0.041,0.107c0.36,0.698,0.772,1.363,1.225,1.993c0.019,0.027,0.053,0.038,0.084,0.029 c1.961-0.607,3.95-1.522,6.002-3.029c0.018-0.013,0.029-0.033,0.031-0.055c0.5-5.177-0.838-9.674-3.548-13.66 C20.342,4.385,20.33,4.375,20.317,4.37z M8.02,15.331c-1.183,0-2.157-1.086-2.157-2.419s0.955-2.419,2.157-2.419 c1.211,0,2.176,1.095,2.157,2.419C10.177,14.246,9.221,15.331,8.02,15.331z M15.995,15.331c-1.182,0-2.157-1.086-2.157-2.419 s0.955-2.419,2.157-2.419c1.211,0,2.176,1.095,2.157,2.419C18.152,14.246,17.206,15.331,15.995,15.331z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,40 @@
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 24 24"
style="enable-background:new 0 0 24 24;"
xml:space="preserve"
fill="currentColor"
width="100%"
height="100%"
>
<g>
<path
style="fill-rule:evenodd;clip-rule:evenodd;"
d="M12,0.296c-6.627,0-12,5.372-12,12c0,5.302,3.438,9.8,8.206,11.387 c0.6,0.111,0.82-0.26,0.82-0.577c0-0.286-0.011-1.231-0.016-2.234c-3.338,0.726-4.043-1.416-4.043-1.416 C4.421,18.069,3.635,17.7,3.635,17.7c-1.089-0.745,0.082-0.729,0.082-0.729c1.205,0.085,1.839,1.237,1.839,1.237 c1.07,1.834,2.807,1.304,3.492,0.997C9.156,18.429,9.467,17.9,9.81,17.6c-2.665-0.303-5.467-1.332-5.467-5.93 c0-1.31,0.469-2.381,1.237-3.221C5.455,8.146,5.044,6.926,5.696,5.273c0,0,1.008-0.322,3.301,1.23 C9.954,6.237,10.98,6.104,12,6.099c1.02,0.005,2.047,0.138,3.006,0.404c2.29-1.553,3.297-1.23,3.297-1.23 c0.653,1.653,0.242,2.873,0.118,3.176c0.769,0.84,1.235,1.911,1.235,3.221c0,4.609-2.807,5.624-5.479,5.921 c0.43,0.372,0.814,1.103,0.814,2.222c0,1.606-0.014,2.898-0.014,3.293c0,0.319,0.216,0.694,0.824,0.576 c4.766-1.589,8.2-6.085,8.2-11.385C24,5.669,18.627,0.296,12,0.296z"
/>
<path
d="M4.545,17.526c-0.026,0.06-0.12,0.078-0.206,0.037c-0.087-0.039-0.136-0.121-0.108-0.18 c0.026-0.061,0.12-0.078,0.207-0.037C4.525,17.384,4.575,17.466,4.545,17.526L4.545,17.526z"
/>
<path
d="M5.031,18.068c-0.057,0.053-0.169,0.028-0.245-0.055c-0.079-0.084-0.093-0.196-0.035-0.249 c0.059-0.053,0.167-0.028,0.246,0.056C5.076,17.903,5.091,18.014,5.031,18.068L5.031,18.068z"
/>
<path
d="M5.504,18.759c-0.074,0.051-0.194,0.003-0.268-0.103c-0.074-0.107-0.074-0.235,0.002-0.286 c0.074-0.051,0.193-0.005,0.268,0.101C5.579,18.579,5.579,18.707,5.504,18.759L5.504,18.759z"
/>
<path
d="M6.152,19.427c-0.066,0.073-0.206,0.053-0.308-0.046c-0.105-0.097-0.134-0.234-0.068-0.307 c0.067-0.073,0.208-0.052,0.311,0.046C6.191,19.217,6.222,19.355,6.152,19.427L6.152,19.427z"
/>
<path
d="M7.047,19.814c-0.029,0.094-0.164,0.137-0.3,0.097C6.611,19.87,6.522,19.76,6.55,19.665 c0.028-0.095,0.164-0.139,0.301-0.096C6.986,19.609,7.075,19.719,7.047,19.814L7.047,19.814z"
/>
<path
d="M8.029,19.886c0.003,0.099-0.112,0.181-0.255,0.183c-0.143,0.003-0.26-0.077-0.261-0.174c0-0.1,0.113-0.181,0.256-0.184 C7.912,19.708,8.029,19.788,8.029,19.886L8.029,19.886z"
/>
<path
d="M8.943,19.731c0.017,0.096-0.082,0.196-0.224,0.222c-0.139,0.026-0.268-0.034-0.286-0.13 c-0.017-0.099,0.084-0.198,0.223-0.224C8.797,19.574,8.925,19.632,8.943,19.731L8.943,19.731z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,32 @@
<svg
enable-background="new 0 0 512 512"
height="100%"
viewBox="0 0 512 512"
width="100%"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<g>
<path
d="m256 116c-77.493 0-140 62.491-140 140 0 54.541 31.615 103.736 80 126.587v33.413c0 11.046 8.954 20 20 20h80c11.046 0 20-8.954 20-20v-33.413c48.385-22.851 80-72.046 80-126.587 0-37.456-14.546-72.629-40.958-99.042-26.413-26.412-61.586-40.958-99.042-40.958zm33.385 234.326c-8.018 2.81-13.385 10.379-13.385 18.875v26.799h-40v-26.799c0-8.496-5.368-16.064-13.385-18.875-39.844-13.964-66.615-51.87-66.615-94.326 0-55.14 44.86-100 100-100s100 44.86 100 100c0 42.456-26.771 80.362-66.615 94.326z"
></path>
<path
d="m256 80c11.046 0 20-8.954 20-20v-40c0-11.046-8.954-20-20-20s-20 8.954-20 20v40c0 11.046 8.954 20 20 20z"
></path>
<path
d="m60 236h-40c-11.046 0-20 8.954-20 20s8.954 20 20 20h40c11.046 0 20-8.954 20-20s-8.954-20-20-20z"
></path>
<path
d="m492 236h-40c-11.046 0-20 8.954-20 20s8.954 20 20 20h40c11.046 0 20-8.954 20-20s-8.954-20-20-20z"
></path>
<path
d="m128.721 100.437-28.284-28.284c-7.811-7.811-20.474-7.811-28.284 0s-7.811 20.474 0 28.284l28.284 28.284c7.81 7.81 20.473 7.811 28.284 0 7.81-7.811 7.81-20.474 0-28.284z"
></path>
<path
d="m439.848 72.152c-7.811-7.811-20.474-7.811-28.284 0l-28.284 28.284c-7.811 7.81-7.811 20.474 0 28.284 7.81 7.81 20.473 7.811 28.284 0l28.284-28.284c7.81-7.81 7.81-20.473 0-28.284z"
></path>
<path
d="m296 472h-80c-11.046 0-20 8.954-20 20s8.954 20 20 20h80c11.046 0 20-8.954 20-20s-8.954-20-20-20z"
></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,154 @@
<svg
width="100%"
height="100%"
viewBox="0 0 263 191"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(3.8096, 0.377)">
<path
d="M83.13363,88.325639 L66.04613,95.611739 C66.03363,95.165439 66.02733,94.717539 66.02733,94.268139 C66.02733,68.242139 87.12553,47.144039 113.15143,47.144039 C139.17743,47.144039 160.27543,68.242139 160.27543,94.268139 C160.27543,99.650039 159.37343,104.821039 157.71243,109.639039 L149.22643,101.889039 L118.83343,95.632039 C118.82043,95.450139 118.81243,95.268339 118.80943,95.086639 C118.75943,92.131339 120.05843,89.227739 122.48543,87.315939 L140.12043,74.717939 L121.10143,85.063539 C119.77743,85.706839 118.36743,86.015539 116.97143,86.015539 C114.09943,86.015539 111.29343,84.707139 109.43543,82.301439 L96.96143,64.504039 L96.95743,64.495739 L96.95743,64.497639 L96.96143,64.504039 L107.20543,83.697439 C108.67743,86.792539 108.38243,90.357139 106.61343,93.115939 L83.32683,88.321639 L83.26903,88.267839 L83.20333,88.295739 L83.06993,88.267839 L83.13363,88.325639 Z"
id="Path"
fill="currentColor"
fill-rule="nonzero"
></path>
<polygon
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
points="227.70443 173.558039 149.22643 101.889039 83.32683 88.321639 83.26913 88.267839 83.20343 88.295739 83.06993 88.267839 83.13373 88.325639 19.38283 115.509039 97.51643 188.275039 162.01143 160.614039"
></polygon>
<polygon
id="Path"
stroke="currentColor"
stroke-width="1.998"
points="149.22643 102.089039 176.45143 63.195239 255.90343 136.647039 227.90343 173.758039"
></polygon>
<line
x1="256.50143"
y1="136.049039"
x2="226.70943"
y2="173.957039"
id="Path"
stroke="currentColor"
stroke-width="4.7952"
></line>
<path
d="M-7.10542736e-15,75.868939 L19.78123,116.081039 L97.28343,187.923039 C97.56243,188.182039 97.99243,187.855039 97.81743,187.517039 L77.50593,148.185039 L-7.10542736e-15,75.868939 Z"
id="Path"
stroke="currentColor"
stroke-width="1.998"
fill="currentColor"
fill-rule="nonzero"
></path>
<path
d="M47.96253,21.318639 L49.95003,21.318639 L50.55943,31.343739 C50.73863,32.928939 52.01313,34.203439 53.60033,34.384639 L63.62543,34.994039 L63.62543,36.981539 L53.60033,37.590939 C52.01513,37.772139 50.74063,39.044739 50.55943,40.631939 L49.95003,50.656939 L47.96253,50.656939 L47.35313,40.631939 C47.17393,39.046739 45.89933,37.772139 44.31213,37.590939 L34.28713,36.981539 L34.28713,34.994039 L44.31213,34.384639 C45.89733,34.203439 47.17193,32.930939 47.35313,31.343739 L47.96253,21.318639 Z"
id="Path"
fill="currentColor"
fill-rule="nonzero"
></path>
<path
d="M187.58043,7.10542736e-15 L189.56743,7.10542736e-15 L190.62343,17.333739 C190.79043,18.837339 191.47143,20.255239 192.54343,21.326639 C193.61443,22.398039 195.03043,23.079139 196.53543,23.246439 L213.86943,24.301839 L213.86943,26.289339 L196.53543,27.344839 C195.03243,27.512139 193.61443,28.193239 192.54343,29.264639 C191.47143,30.334039 190.79043,31.751939 190.62343,33.257539 L189.56743,50.591239 L187.58043,50.591239 L186.52443,33.257539 C186.35743,31.753939 185.67643,30.336039 184.60543,29.264639 C183.53343,28.193239 182.11743,27.512139 180.61243,27.344839 L163.27843,26.289339 L163.27843,24.301839 L180.61243,23.246439 C182.11743,23.079139 183.53343,22.398039 184.60543,21.326639 C185.67643,20.255239 186.35743,18.839339 186.52443,17.333739 L187.58043,7.10542736e-15 Z"
id="Path"
fill="currentColor"
fill-rule="nonzero"
></path>
<path
d="M178.83743,92.422039 C178.83743,103.144039 176.30643,113.275039 171.81143,122.250039"
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
></path>
<path
d="M147.69943,35.989839 C159.31343,43.320439 168.50843,54.138039 173.81143,66.971039"
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
></path>
<path
d="M70.91403,40.016539 C82.26143,31.072839 96.58643,25.737739 112.15543,25.737739"
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
></path>
<path
d="M45.47163,92.422039 C45.47163,79.718439 49.02443,67.845339 55.18803,57.740639"
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
></path>
<path
d="M169.12143,92.422039 C169.12143,60.960839 143.61643,35.456139 112.15543,35.456139 C80.69423,35.456139 55.18943,60.960839 55.18943,92.422039 C55.18943,93.897739 55.24523,95.359439 55.35473,96.807239 C55.35473,96.807239 56.18123,107.886039 61.57213,118.239039"
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
></path>
<path
d="M70.91213,27.223339 C82.84103,19.661739 96.98643,15.284539 112.15343,15.284539 C124.97243,15.284539 137.06043,18.411139 147.69743,23.943439"
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
></path>
<path
d="M34.97213,109.109039 C32.86713,99.039739 32.82933,89.016639 34.56993,79.485439"
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
></path>
<path
d="M187.91143,107.027039 C185.03143,122.053039 177.79243,135.531039 167.56043,146.090039"
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
></path>
<path
d="M171.72043,43.406039 C180.58643,54.165939 186.59143,67.371339 188.57443,81.857339"
id="Path"
stroke="currentColor"
stroke-width="1.998"
stroke-linejoin="round"
></path>
<path
d="M151.04443,31.977039 C153.75843,31.977039 155.95743,29.777439 155.95743,27.064039 C155.95743,24.350739 153.75843,22.151139 151.04443,22.151139 C148.33143,22.151139 146.13143,24.350739 146.13143,27.064039 C146.13143,29.777439 148.33143,31.977039 151.04443,31.977039 Z"
id="Path"
fill="currentColor"
fill-rule="nonzero"
></path>
<path
d="M188.57443,107.874039 C191.28743,107.874039 193.48743,105.674039 193.48743,102.961039 C193.48743,100.248039 191.28743,98.047939 188.57443,98.047939 C185.86043,98.047939 183.66143,100.248039 183.66143,102.961039 C183.66143,105.674039 185.86043,107.874039 188.57443,107.874039 Z"
id="Path"
fill="currentColor"
fill-rule="nonzero"
></path>
<path
d="M61.11413,120.259039 C63.82743,120.259039 66.02703,118.059039 66.02703,115.346039 C66.02703,112.632039 63.82743,110.433039 61.11413,110.433039 C58.40073,110.433039 56.20113,112.632039 56.20113,115.346039 C56.20113,118.059039 58.40073,120.259039 61.11413,120.259039 Z"
id="Path"
fill="currentColor"
fill-rule="nonzero"
></path>
<path
d="M163.76243,160.959039 L164.13743,160.546039 C164.13543,160.544039 107.72143,109.963039 107.72143,109.963039 C107.51543,109.782039 107.20343,109.792039 107.01243,109.989039 C106.81443,110.192039 106.82043,110.514039 107.02443,110.712039 L107.03943,110.726039 L107.03743,110.728039 L107.08743,110.773039 L159.57443,161.621039 L95.88963,187.925039 L97.75343,190.000039 L163.34043,164.664039 L228.10343,175.351039 L227.70543,173.558039 L163.76243,160.959039 Z"
id="Path"
fill="currentColor"
fill-rule="nonzero"
></path>
<path
d="M119.24743,95.716939 C119.33943,96.965139 119.66143,98.211839 120.22943,99.394139 L131.22243,119.797039 L117.83543,100.878039 C114.87243,97.078139 109.66343,95.857339 105.32143,97.942439 L84.91893,108.935039 L103.83743,95.548639 C104.76643,94.824839 105.54043,93.966939 106.15043,93.020439 L119.24743,95.716939 Z"
id="Path"
fill="currentColor"
fill-rule="nonzero"
></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,16 @@
<svg
fill="currentColor"
fill-rule="evenodd"
height="100%"
style="flex:none;line-height:1"
viewBox="0 0 24 24"
width="100%"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"
></path>
<path
d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1012 B

View File

@@ -0,0 +1,11 @@
<svg
fill="currentColor"
width="100%"
height="100%"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m447.585 186.766a64.493 64.493 0 0 0 -58.019 36.448l-99.438-26.64c.117-1.581.2-3.172.2-4.782a64 64 0 0 0 -8.919-32.647l27.9-24.43a53.95 53.95 0 1 0 -15.829-18.041l-27.898 24.426a64.25 64.25 0 0 0 -87.894 8.051l-52.744-31.37a64.661 64.661 0 1 0 -12.308 20.6l52.744 31.37a64.265 64.265 0 0 0 -2.148 36.846l-39.61 17.661a44.923 44.923 0 1 0 9.794 21.911l39.613-17.663a64.686 64.686 0 0 0 32.563 24.4l-13.305 99.034a64.448 64.448 0 1 0 23.783 3.206l13.306-99.034a63.96 63.96 0 0 0 26.83-7.494l22.639 28.154a40.7 40.7 0 1 0 18.7-15.041l-22.655-28.174a64.619 64.619 0 0 0 9.037-13.8l99.438 26.64c-.117 1.581-.2 3.172-.2 4.782a64.415 64.415 0 1 0 64.414-64.415zm-383.17-50.608a40.415 40.415 0 1 1 40.414-40.415 40.461 40.461 0 0 1 -40.414 40.415zm24.436 137.814a21.068 21.068 0 1 1 21.069-21.072 21.092 21.092 0 0 1 -21.069 21.072zm252.149-212.991a30.025 30.025 0 1 1 -30.024 30.025 30.058 30.058 0 0 1 30.024-30.025zm-155.5 130.811a40.415 40.415 0 1 1 40.414 40.414 40.46 40.46 0 0 1 -40.414-40.414zm50.672 224.465a40.415 40.415 0 1 1 -40.414-40.415 40.46 40.46 0 0 1 40.409 40.415zm93.012-116.75a16.652 16.652 0 1 1 -16.652-16.651 16.671 16.671 0 0 1 16.647 16.651zm118.401-7.907a40.415 40.415 0 1 1 40.415-40.419 40.46 40.46 0 0 1 -40.415 40.419z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,12 @@
<svg
enable-background="new 0 0 512 512"
height="100%"
viewBox="0 0 512 512"
width="100%"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<path
d="m489.514 296.695-21.3-17.534c-14.59-12.011-14.564-34.335.001-46.322l21.299-17.534c15.157-12.479 19.034-33.877 9.218-50.882l-42.058-72.846c-9.818-17.004-30.292-24.344-48.674-17.458l-25.835 9.679c-17.696 6.628-37.016-4.551-40.117-23.161l-4.535-27.214c-3.228-19.366-19.821-33.423-39.455-33.423h-84.115c-19.635 0-36.229 14.057-39.456 33.424l-4.536 27.213c-3.107 18.643-22.453 29.778-40.116 23.162l-25.835-9.68c-18.383-6.886-38.855.455-48.674 17.458l-42.057 72.845c-9.817 17.003-5.941 38.402 9.218 50.882l21.299 17.534c14.592 12.012 14.563 34.334 0 46.322l-21.3 17.534c-15.158 12.48-19.035 33.879-9.218 50.882l42.058 72.846c9.818 17.003 30.286 24.344 48.674 17.458l25.834-9.679c17.699-6.631 37.015 4.556 40.116 23.161l4.536 27.212c3.228 19.369 19.822 33.426 39.456 33.426h84.115c19.634 0 36.228-14.057 39.455-33.424l4.535-27.212c3.106-18.638 22.451-29.781 40.117-23.161l25.836 9.678c18.387 6.887 38.856-.454 48.674-17.458l42.059-72.847c9.815-17.003 5.938-38.402-9.219-50.881zm-67.481 103.728-25.835-9.679c-41.299-15.471-86.37 10.63-93.605 54.043l-4.535 27.213h-84.115l-4.536-27.213c-7.249-43.497-52.386-69.484-93.605-54.043l-25.835 9.679-42.057-72.846 21.299-17.534c34.049-28.03 33.978-80.114 0-108.086l-21.299-17.534 42.058-72.846 25.834 9.679c41.3 15.47 86.37-10.63 93.605-54.043l4.535-27.213h84.115l4.535 27.213c7.25 43.502 52.389 69.481 93.605 54.043l25.835-9.679 42.067 72.836s-.003.003-.011.009l-21.298 17.534c-34.048 28.028-33.98 80.113-.001 108.086l21.3 17.534zm-166.033-243.09c-54.405 0-98.667 44.262-98.667 98.667s44.262 98.667 98.667 98.667 98.667-44.262 98.667-98.667-44.262-98.667-98.667-98.667zm0 157.334c-32.349 0-58.667-26.318-58.667-58.667s26.318-58.667 58.667-58.667 58.667 26.318 58.667 58.667-26.318 58.667-58.667 58.667z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,18 @@
<svg
enable-background="new 0 0 512 512"
height="100%"
width="100%"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<g>
<path
d="m256 306.234c14.165 0 25.649-11.485 25.649-25.649v-102.586c0-14.177-11.485-25.649-25.649-25.649s-25.649 11.472-25.649 25.649v102.585c-.001 14.166 11.484 25.65 25.649 25.65z"
></path>
<path
d="m77.067 487.91h357.865c27.816 0 52.739-14.39 66.641-38.487 13.902-24.084 13.902-52.852 0-76.936l-178.944-309.923c-13.902-24.083-38.813-38.474-66.629-38.474s-52.727 14.39-66.628 38.474l-178.946 309.911c-13.902 24.096-13.902 52.865 0 76.948 13.902 24.096 38.825 38.487 66.641 38.487zm-22.205-89.773 178.92-309.923c6.688-11.572 17.759-12.825 22.218-12.825 4.458 0 15.53 1.252 22.218 12.825l178.92 309.935c6.688 11.56 2.229 21.78 0 25.637-2.217 3.857-8.842 12.825-22.205 12.825h-357.866c-13.363 0-19.989-8.967-22.205-12.825-2.229-3.857-6.688-14.077 0-25.649z"
></path>
<circle cx="256" cy="384.247" r="25.649"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import '../../app.css';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import Flex from '$components/Flex.svelte';
import Nav from '$components/Nav.svelte';
import Titlebar from '$components/Titlebar.svelte';
interface Props extends HTMLAttributes<HTMLDivElement> {
titlebar?: Snippet;
}
const { titlebar, children }: Props = $props();
</script>
<div data-tauri-drag-region class="fixed top-0 left-0 h-8 w-full"></div>
<Flex class="relative h-screen w-screen items-start overflow-hidden">
<Nav id="nav" class="w-nav fixed top-0 left-0 h-full" />
<Flex id="content" class="w-content-minus-nav fixed top-0 left-[var(--width-nav)] flex-col">
<Titlebar class="w-full">
{@render titlebar?.()}
</Titlebar>
<section
class="w-content-minus-nav fixed top-[var(--height-titlebar)] left-0 ml-[var(--width-nav)] h-full min-h-screen overflow-y-scroll"
>
{@render children?.()}
</section>
</Flex>
</Flex>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import { page } from '$app/state';
type Props = SvelteHTMLElements['a'] & {
href: string;
prefix?: string;
activeClass?: string;
};
const { children, class: cls = '', href, prefix, activeClass, ...rest }: Props = $props();
const path = page.url.pathname;
const isActive = path == href || (prefix && path.startsWith(prefix));
</script>
<a
{...rest}
{href}
class={twMerge('border-l border-transparent', cls?.toString(), isActive ? activeClass : '')}
>
{@render children?.()}
</a>
<style>
a {
transition: all 0.3s linear;
}
</style>

View File

@@ -0,0 +1,75 @@
<script lang="ts" module>
export interface MenuItem {
icon: string;
label: string;
style?: string;
onclick: () => void;
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Flex from '$components/Flex.svelte';
import Svg from '$components/Svg.svelte';
import closables from '$lib/closables';
type Props = SvelteHTMLElements['div'] & {
items: MenuItem[];
};
const { items, class: cls = '' }: Props = $props();
let isOpen = $state(false);
let ref: HTMLButtonElement;
function toggle() {
isOpen = isOpen ? false : true;
}
function close() {
isOpen = false;
}
onMount(() => {
closables.register(ref, close);
});
</script>
<Flex class={twMerge('relative justify-center', cls?.toString())}>
<button
bind:this={ref}
onclick={() => toggle()}
class={`${isOpen ? 'bg-medium border-light z-30 border' : ''} border-light
text-dark/75 z-10 h-8 w-10 rounded-t-md
border-b-0 text-center text-[6px]
leading-8 font-bold transition-none hover:cursor-pointer`}
>
•••
</button>
{#if isOpen}
<Flex
class="border-light bg-medium absolute top-[calc(--spacing(8)-1px)] right-0
z-20 min-w-56 flex-col rounded-md rounded-tr-none
border p-1 group-hover:block"
>
{#each items as item, i (i)}
<button
onclick={item.onclick}
class={twMerge(
'hover:bg-light/25 flex w-full flex-row items-start justify-between rounded-md p-3 py-2 hover:cursor-pointer',
item.style
)}
>
<div class="h-4 w-4">
<Svg name={item.icon} />
</div>
<p>{item.label}</p>
</button>
{/each}
</Flex>
{/if}
</Flex>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from 'svelte';
import Flex from '$components/Flex.svelte';
import Thought from '$components/Thought.svelte';
import markdown from '$lib/markdown';
import type { IMessage } from '$lib/models/message';
interface Props {
message: IMessage;
}
const { message }: Props = $props();
// Rounded css class.
let rounded = $state('rounded-full');
// svelte-ignore non_reactive_update
let ref: HTMLDivElement;
onMount(() => {
// If the message spans 2 or more lines, full rounded looks absurd.
if (ref?.clientHeight > 28) {
rounded = 'rounded-2xl';
}
});
</script>
{#if message.role == 'user'}
<Flex class={`bg-light self-end ${rounded} px-8 py-3`}>
<div bind:this={ref}>{message.content}</div>
</Flex>
{:else}
{#if message.thought}
<Thought thought={message.thought} />
{/if}
<Flex class="text-medium w-full justify-between p-2 text-xs">
<p class="message markdown-body text-sm whitespace-normal">
{@html markdown.render(message.content)}
</p>
</Flex>
{/if}

51
src/components/Nav.svelte Normal file
View File

@@ -0,0 +1,51 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Link from '$components/Link.svelte';
import Svg from '$components/Svg.svelte';
const { class: cls }: SvelteHTMLElements['nav'] = $props();
</script>
<nav
class={twMerge(
'bg-md text-medium border-light flex flex-col items-center gap-8 border-r pt-20',
cls?.toString()
)}
>
<!--
This link MUST NOT preload data on hover, since it redirects when it does.
Which means we'd navigate on hover instead of click.
-->
<Link
href="/chat/latest"
aria-label="chat"
prefix="/chat"
activeClass="text-purple"
data-sveltekit-preload-data="off"
>
<Svg name="Chat" />
</Link>
<Link href="/mcp-servers" aria-label="mcp-servers" activeClass="text-purple">
<Svg name="MCP" />
</Link>
<Link href="/models" aria-label="models" activeClass="text-purple">
<Svg name="Models" />
</Link>
<div class="grow"></div>
<Link href="/settings" aria-label="settings" activeClass="text-purple" class="text-dark pb-8">
<Svg name="Settings" />
</Link>
</nav>
<style>
nav :global(svg) {
width: 32px;
height: 32px;
}
</style>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Flex from '$components/Flex.svelte';
import closables from '$lib/closables';
interface Props extends HTMLAttributes<HTMLDivElement> {
options: string[];
value?: string;
onSelect: () => Promise<void>;
}
let { options, value = $bindable(), onSelect, class: cls = '' }: Props = $props();
let isOpen = $state(false);
let ref: ReturnType<typeof Flex>;
function toggle(e: Event) {
isOpen = isOpen ? false : true;
e.stopPropagation();
}
async function select(option: string) {
close();
value = option;
await onSelect();
}
function close() {
isOpen = false;
}
onMount(() => {
closables.register(ref, close);
});
</script>
<Flex
bind:this={ref}
class={twMerge('bg-medium relative h-10 w-32 hover:cursor-pointer', cls?.toString())}
>
<Flex
onclick={(e) => toggle(e)}
class={`border-light absolute top-0 left-0 w-full justify-between rounded-md border p-2 px-4 ${isOpen ? 'rounded-b-none' : ''}`}
>
<p>{value}</p>
<p></p>
</Flex>
{#if isOpen}
<Flex
class="border-light bg-medium absolute top-12 left-0 z-50 mt-[1px]
w-full flex-col items-start rounded-md rounded-t-none border"
>
{#each options as option (option)}
<button
onclick={async () => await select(option)}
class="bg-dark border-b-light w-full border-b p-2 px-4 text-left last:border-b-0 hover:cursor-pointer"
>
{option}
</button>
{/each}
</Flex>
{/if}
</Flex>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
const { name, class: cls = '' } = $props();
</script>
{#await import(`$components/Icons/${name}.svelte`) then Module}
<p class={cls}>
<Module.default />
</p>
{/await}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import Svg from './Svg.svelte';
const { thought } = $props();
let isOpen = $state(false);
function toggle() {
isOpen = isOpen ? false : true;
}
</script>
<thought class="text-medium border-light mb-4 w-full rounded-xl border">
<button
onclick={() => toggle()}
class="flex w-full items-center gap-4 p-2 px-4 hover:cursor-pointer"
>
<Svg name="Lightbulb" class="h-6 w-6" />
Model Thoughts
</button>
{#if isOpen}
<p class="p-4 px-6 whitespace-pre-wrap">
{thought}
</p>
{/if}
</thought>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Flex from '$components/Flex.svelte';
const { children, class: cls }: SvelteHTMLElements['div'] = $props();
</script>
<Flex
id="titlebar"
data-tauri-drag-region
class={twMerge('h-titlebar border-b-light w-full border-b', cls?.toString())}
>
{@render children?.()}
</Flex>

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { kebabCase } from 'change-case';
import type { HTMLInputAttributes } from 'svelte/elements';
import Flex from '$components/Flex.svelte';
import type { CheckboxEvent } from '$lib/types';
interface Props extends HTMLInputAttributes {
value?: 'on' | 'off';
label: string;
disabled?: boolean;
onEnable: () => void;
onDisable: () => void;
}
const { value, label, disabled, onEnable, onDisable, ...rest }: Props = $props();
let enabled = $state(false);
function onChange(e: CheckboxEvent) {
if (e.currentTarget.checked) {
enabled = true;
onEnable();
} else {
enabled = false;
onDisable();
}
}
</script>
<Flex>
<div class="toggle">
<input
{...rest}
{disabled}
checked={value == 'on'}
onchange={onChange}
type="checkbox"
name={kebabCase(label)}
class:disabled
class="check"
/>
<label class:disabled for={kebabCase(label)}></label>
</div>
<p class:disabled class={`${enabled ? 'text-grey-200' : 'text-grey-500'} ml-4`}>
{label}
</p>
</Flex>
<style>
.toggle {
position: relative;
width: 50px;
height: 25px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.check {
position: absolute;
width: 50px;
height: 25px;
top: 0;
left: 0;
z-index: 10;
opacity: 0;
}
label {
position: absolute;
top: 0;
left: 0;
width: 50px;
height: 25px;
border-radius: 50px;
cursor: pointer;
background: linear-gradient(to bottom, #111, #333);
transition: all 0.3s ease;
z-index: 0;
}
label.disabled {
opacity: 25%;
}
label:after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 21px;
height: 21px;
border-radius: 50%;
background-color: #999;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}
.check:checked + label {
background: linear-gradient(to bottom, #4cd96466, #5de24e66);
}
.check:checked + label:after {
transform: translateX(25px);
background-color: #ccc;
}
p.disabled {
opacity: 25%;
}
</style>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { WELCOME_AGREED } from '$lib/const';
import Flex from '$components/Flex.svelte';
import Config from '$lib/config';
async function accept() {
await Config.set(WELCOME_AGREED, true);
}
</script>
<Flex
class="border-light absolute top-[50%] left-[50%] w-[calc(100vw*0.5)]
-translate-[50%] items-start rounded-3xl border p-12"
>
<img class="mr-12 h-48 w-48" src="/images/tome.png" alt="tome" />
<Flex class="flex-col items-start gap-4">
<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
<strong>Technical Preview</strong> as we explore making local LLMs, MCP, and AI app composition
a better experience.
</p>
<p>
This is an extremely early build. There will be problems edges are rough features
are lacking we know 🙂 Let us know what you're running into and what you'd like to
see.
</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"
>
Sounds good, let's go!
</button>
</Flex>
</Flex>

1
src/components/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

46
src/hooks.client.ts Normal file
View File

@@ -0,0 +1,46 @@
import '$lib/ext';
import type { ClientInit } from "@sveltejs/kit";
import { WELCOME_AGREED } from "$lib/const";
import Config from "$lib/config";
import * as llm from '$lib/llm';
import { info } from '$lib/logger';
import App from "$lib/models/app";
import McpServer from "$lib/models/mcp-server";
import Message from "$lib/models/message";
import Model from '$lib/models/model.svelte';
import Session from "$lib/models/session";
import Setting from "$lib/models/setting";
import startup, { StartupCheck } from "$lib/startup";
// App Initialization
export const init: ClientInit = async () => {
info('initializing');
await App.sync();
await Session.sync();
await Message.sync();
await McpServer.sync();
await Setting.sync();
info('[green]✔ database synced');
const client = new llm.Client();
await startup.addCheck(
StartupCheck.Ollama,
async () => await client.connected(),
);
await startup.addCheck(
StartupCheck.MissingModels,
async () => await client.hasModels(),
async () => await Model.sync(),
);
await startup.addCheck(
StartupCheck.Agreement,
async () => await Config.get(WELCOME_AGREED) === true,
);
}

27
src/lib/closables.ts Normal file
View File

@@ -0,0 +1,27 @@
// Callable implemented by Closable components
type Closable = (() => Promise<void>) | (() => void);
// List of callables to invoke
// eslint-disable-next-line
const closables: Array<[any, Closable]> = [];
// Manage a list of functions that should be called when the window is clicked.
//
// Used in custom dropdowns/menus to close when the User clicks outside of the
// element.
//
// Triggered by `routes/+layout.svelte`
//
export default {
register(ele: any, fn: Closable) { // eslint-disable-line
closables.push([ele, fn]);
},
close(e: Event) {
closables.forEach(([ele, fn]) => {
if (e.target !== ele) {
fn();
}
});
}
};

36
src/lib/config.ts Normal file
View File

@@ -0,0 +1,36 @@
import { BaseDirectory, exists, readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"
export const FILENAME = "tome.conf.json";
export default class Config {
static async all(): Promise<Record<string, unknown>> {
return await this.config();
}
static async get<T>(key: string): Promise<T> {
return (await this.config())[key] as T;
}
static async set<T>(key: string, value: T): Promise<boolean> {
const config = await this.config();
config[key] = value;
await writeTextFile(FILENAME, JSON.stringify(config, null, 4), this.opt());
return true;
}
private static async config(): Promise<Record<string, unknown>> {
// If we haven't created the config file yet, copy over the default one
// from the App Bundle into the App Data directory.
if (!await exists(FILENAME, this.opt())) {
await writeTextFile(FILENAME, "{}", this.opt());
}
return JSON.parse(await readTextFile(FILENAME, this.opt()));
}
private static opt() {
return {
baseDir: BaseDirectory.AppData,
}
}
}

12
src/lib/const.ts Normal file
View File

@@ -0,0 +1,12 @@
// ~/Library/Application Support/co.runebook/tome.db
export const DATABASE_URL = 'sqlite:tome.db';
// The seeded chat app is always id 1.
export const CHAT_APP_ID = 1;
// Key for config that stores whether the user has clicked "I agree" on the
// welcome screen.
export const WELCOME_AGREED = 'welcome-agreed';
// `key` for settings record where the Ollama URL lives
export const OLLAMA_URL_CONFIG_KEY = 'ollama-url';

83
src/lib/dispatch.ts Normal file
View File

@@ -0,0 +1,83 @@
import { invoke } from "@tauri-apps/api/core";
import * as llm from "$lib/llm";
import App from "$lib/models/app";
import type { IMessage } from "$lib/models/message";
import Session, { type ISession } from "$lib/models/session";
import Setting from "$lib/models/setting";
export async function dispatch(session: ISession, model: string, prompt?: string): Promise<IMessage> {
const app = App.find(session.appId as number);
const client = new llm.Client();
if (!app) {
throw "Missing app";
}
if (prompt) {
await Session.addMessage(session, {
role: 'user',
content: prompt,
});
}
const messages = (Session.messages(session)).map(m => ({
role: m.role,
content: m.content,
name: m.name,
tool_calls: m.toolCalls,
}));
const message = await client.chat(
model,
messages,
await Session.tools(session),
);
if (message.toolCalls?.length) {
for (const call of message.toolCalls) {
const content: string = await invoke('call_mcp_tool', {
sessionId: session.id,
name: call.function.name,
arguments: call.function.arguments,
});
await Session.addMessage(session, {
role: 'assistant',
content: '',
toolCalls: [call],
});
await Session.addMessage(session, {
role: 'tool',
content,
name: call.function.name,
});
return await dispatch(session, model);
}
}
await Session.addMessage(session, message);
return message;
}
export async function send(model: string, messages: llm.Message[], tools: llm.Tool[] = []): Promise<Response> {
const body: llm.Request = {
model,
tools,
messages,
stream: false,
};
return (
await fetch(`${Setting.OllamaUrl}/api/chat`, {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
})
);
}

69
src/lib/ext.ts Normal file
View File

@@ -0,0 +1,69 @@
// Javascript stdlib extensions
//
// This file should only include functions added to standard Javascript
// objects. Each function needs a cooresponding type definition in
// `src/app.d.ts`.
// Return the Object without k/v pairs where the value is explicitly undefined.
//
// Typically you'll use this after mapping a bunch of values where some may be
// undefined.
//
// @example
// ```ts
// const compacted = Object.compact({ a: 1, b: true, c: undefined });
// assertEq(compacted, { a: 1, b: true });
// ```
//
Object.compact = function <T>(o: Obj): T {
return Object.fromEntries(
Object.entries(o).filter(([_, v]) => v !== undefined),
) as T;
}
// Return the Object without specific k/v pairs, specified by key.
//
// @example
// ```ts
// const subobj = Object.without({a: 1, b: 2}, ['b']);
// assertEq(subobj, {a: 1})
// ```
//
Object.without = function <T>(o: Obj, keys: string[]): T {
return Object.fromEntries(
Object.entries(o).filter(([k, _]) => !keys.includes(k)),
) as T;
}
// Remove, and return, a value by key
//
// @example
// ```ts
// const value = Object.remove({a: 1, b: 2}, 'b');
// assertEq(value, 2);
// ```
//
Object.remove = function <T>(o: Obj, key: string): T | undefined {
if (Object.hasOwn(o, key)) {
const value = o[key];
delete o[key];
return value;
}
}
// Sort an array of objects by a specific key.
//
// @example
// ```ts
// const array = [{color: 'red'}, {color: 'blue'}];
// const sorted = array.sortBy('color');
// assertEq(sorted, [{color: 'blue'}, {color: 'red'}]);
// ```
//
Array.prototype.sortBy = function <T extends Obj>(this: T[], key: string): T[] {
return this.sort((a, b) => {
if (a[key] < b[key]) return -1;
if (a[key] > b[key]) return 1;
return 0;
});
}

104
src/lib/http.ts Normal file
View File

@@ -0,0 +1,104 @@
import { info } from "$lib/logger";
type Response<T> = globalThis.Response | T | undefined;
interface Options extends RequestInit {
url?: string;
raw?: boolean;
raise?: boolean;
}
export abstract class HttpClient {
options: Options = {};
abstract get url(): string;
constructor(options: Options = {}) {
this.options = { ...this.options, ...options };
}
async get<T>(uri: string, options: Options = {}): Promise<Response<T>> {
const raw = Object.remove(options, 'raw');
const raise: boolean | undefined = Object.remove(options, 'raise');
const response = await this.request(uri, { ...options, method: 'GET' });
return raw ? response : await this.parse(response, raise);
}
async post<T>(uri: string, options: Options = {}): Promise<Response<T>> {
const raw = Object.remove(options, 'raw');
const raise: boolean | undefined = Object.remove(options, 'raise');
const response = await this.request(uri, { ...options, method: 'POST' });
return raw ? response : await this.parse(response, raise);
}
async put<T>(uri: string, options: Options = {}): Promise<Response<T>> {
const raw = Object.remove(options, 'raw');
const raise: boolean | undefined = Object.remove(options, 'raise');
const response = await this.request(uri, { ...options, method: 'PUT' });
return raw ? response : await this.parse(response, raise);
}
async delete<T>(uri: string, options: Options = {}): Promise<Response<T>> {
const raw = Object.remove(options, 'raw');
const raise: boolean | undefined = Object.remove(options, 'raise');
const response = await this.request(uri, { ...options, method: 'DELETE' });
return raw ? response : await this.parse(response, raise);
}
async head<T>(uri: string, options: Options = {}): Promise<Response<T>> {
const raw = Object.remove(options, 'raw');
const raise: boolean | undefined = Object.remove(options, 'raise');
const response = await this.request(uri, { ...options, method: 'HEAD' });
return raw ? response : await this.parse(response, raise);
}
async parse<T>(response: globalThis.Response, raise: boolean = true): Promise<T | undefined> {
try {
return await response.json() as T;
} catch (error) {
if (raise) {
throw error;
}
}
}
async request(uri: string, options: Options = {}): Promise<globalThis.Response> {
let response;
const url = Object.remove(options, 'url') || this.url;
const opt = { ...this.options, ...options };
try {
response = await fetch(`${url}${uri}`, opt);
} catch (err) {
if (typeof err == 'string') {
info(`${opt.method} ${url}${uri}: ${err}`);
return this.response(500);
} else if (err instanceof Error) {
if (err.name == 'TimeoutError') {
info(`${opt.method} ${url}${uri}: 408 Timeout`)
return this.response(408);
} else {
info(`${opt.method} ${url}${uri}: ${err.name}: ${err.message}`, err.stack);
return this.response(500);
}
}
}
if (!response) {
return this.response(500);
}
if (response.status > 399) {
info(`${opt.method} ${url}${uri}: ${response.status} ${response.statusText}`);
}
info(`${opt.method} ${url}${uri}: ${response.status} ${response.statusText}`);
return response;
}
response(status: number = 500, body?: string): globalThis.Response {
return new globalThis.Response(body, { status });
}
}

105
src/lib/llm.d.ts vendored Normal file
View File

@@ -0,0 +1,105 @@
export interface Request {
model: string;
stream: boolean;
tools: Tool[];
messages: Message[];
}
export interface Response {
created_at: string;
done: boolean;
done_reason: string;
eval_count: number;
eval_duration: number;
load_duration: number;
message: {
content: string;
role: Role;
tool_calls: ToolCall[];
};
model: string;
prompt_eval_count: number;
prompt_eval_duration: number;
total_duration: number;
}
export interface Model {
name: string;
modifiedAt: string;
size: number;
digest: string;
details: {
parentModel: string;
format: string;
family: string;
families: string[];
parameter_size: string;
quantization_level: string;
};
model_info: {
"general.architecture": string;
"general.file_type": number;
"general.parameter_count": number;
"general.quantization_version": number;
"llama.attention.head_count": number;
"llama.attention.head_count_kv": number;
"llama.attention.layer_norm_rms_epsilon": number;
"llama.block_count": number;
"llama.context_length": number;
"llama.embedding_length": number;
"llama.feed_forward_length": number;
"llama.rope.dimension_count": number;
"llama.rope.freq_base": number;
"llama.vocab_size": number;
"tokenizer.ggml.bos_token_id": number;
"tokenizer.ggml.eos_token_id": number;
"tokenizer.ggml.merges": string[];
"tokenizer.ggml.model": string;
"tokenizer.ggml.pre": string;
"tokenizer.ggml.token_type": string[];
"tokenizer.ggml.tokens": string[];
};
capabilities?: string[];
}
export type Tag = Pick<Model, 'name' | 'modifiedAt' | 'size' | 'digest' | 'details'>;
export type Role = 'system' | 'tool' | 'user' | 'assistant';
export interface Tags {
models: Tag[];
}
export interface Message {
role: Role;
content: string;
name: string;
tool_calls?: Record<string, any>; // eslint-disable-line
}
export interface ToolCall {
function: {
name: string;
// We have no way of knowing what the LLM will pass
// eslint-disable-next-line
arguments: Record<string, any>;
}
}
export interface Tool {
type: string;
function: {
name: string;
description: string;
parameters: {
type: string;
required: string[];
properties: Record<string, Property>;
}
}
}
interface Property {
type: string;
description: string;
}

80
src/lib/llm.ts Normal file
View File

@@ -0,0 +1,80 @@
import type { IMessage } from "./models/message";
import { HttpClient } from "$lib/http";
import type { Message, Model, Response, Tags, Tool } from "$lib/llm.d";
import Setting from "$lib/models/setting";
export * from '$lib/llm.d';
export class Client extends HttpClient {
options: RequestInit = {
signal: AbortSignal.timeout(30000),
headers: {
'Content-Type': 'application/json',
}
};
get url() {
return Setting.OllamaUrl;
}
async chat(model: string, messages: Message[], tools: Tool[] = []): Promise<IMessage> {
const body = JSON.stringify({
model,
messages,
tools,
stream: false,
});
const response = await this.post('/api/chat', { body }) as Response;
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<Model[]> {
return (
await this.get('/api/tags') as Tags
).models as Model[];
}
async info(name: string): Promise<Model> {
const body = JSON.stringify({ name });
return (
await this.post('/api/show', { body })
) as Model;
}
async connected(): Promise<boolean> {
return (
await this.get('', { raw: true }) as globalThis.Response
).status == 200;
}
async hasModels(): Promise<boolean> {
return (
await this.get('/api/tags', { raise: false }) as Tags
)?.models.length > 0;
}
}

65
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,65 @@
/* eslint-disable */
import moment from "moment";
export enum Level {
LOG = 'LOG',
INFO = 'INFO',
DEBUG = 'DEBUG',
ERROR = 'ERROR',
}
const colors = {
grey: '#999999',
blue: '#a9b1d6',
yellow: '#ffc777',
green: '#c3e88d',
red: '#ff757f',
purple: '#bb9af7',
white: '#ffffff',
default: '#ffffff',
};
const levelColors = {
[Level.LOG]: 'grey',
[Level.INFO]: 'blue',
[Level.DEBUG]: 'yellow',
[Level.ERROR]: 'red',
};
export function log(text: string, ...rest: any[]) {
console.log(...fmt(Level.LOG, text), ...rest);
};
export function info(text: string, ...rest: any[]) {
console.info(...fmt(Level.INFO, text), ...rest);
};
export function debug(text: string, ...rest: any[]) {
console.debug(...fmt(Level.DEBUG, text), ...rest);
};
export function error(text: string, ...rest: any[]) {
console.error(...fmt(Level.ERROR, text), ...rest);
};
function fmt(level: Level, text: string): string[] {
const now = moment();
const date = now.format('YYYY-MM-DD');
const time = now.format('hh:mm:ss');
const lvl = levelColors[level];
return colorize(`[grey]${date}\t${time}\t\ttome\t[${lvl}]${level}\t\t[white]${text}`);
}
function colorize(text: string): string[] {
let args: string[] = [];
text = text.replace(/\[\w+\]/g, (block) => {
const code = block.replace('[', '').replace(']', '');
const color = (colors as Obj)[code];
args.push(`color: ${color};`);
return '%c';
});
return [text, ...args];
}

15
src/lib/markdown.ts Normal file
View File

@@ -0,0 +1,15 @@
import { marked } from "marked";
export default {
render(content: string): string {
marked.use({
renderer: {
link({ href, text }): string {
return `<a href="${href}" target="_blank">${text}</a>`;
}
}
});
return marked.parse(content, { async: false });
}
};

39
src/lib/mcp.ts Normal file
View File

@@ -0,0 +1,39 @@
import { invoke } from "@tauri-apps/api/core";
import * as llm from '$lib/llm';
import type { ISession } from "$lib/models/session";
export interface Tool {
name: string;
description: string;
inputSchema: InputSchema;
}
export interface InputSchema {
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<llm.Tool[]> {
return (
await invoke<Tool[]>('get_mcp_tools', { sessionId: session.id })
).map(tool => {
return {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: {
type: 'object',
required: tool.inputSchema.required,
properties: tool.inputSchema.properties,
}
}
};
});
}

137
src/lib/models/app.ts Normal file
View File

@@ -0,0 +1,137 @@
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 default(): IApp {
return {
name: 'Unknown',
description: '',
readme: '',
image: '',
interface: Interface.Chat,
nodes: [],
mcpServers: [],
}
}
static hasContext(app: IApp): boolean {
return app.nodes?.find(n => n.type == NodeType.Context) !== undefined;
}
static context(app: IApp): string {
return app.nodes
.filter(n => n.type == NodeType.Context)
.map(n => n.config.value)
.join('\n\n');
}
static addNode(app: IApp, node: Node): IApp {
app.nodes.push(node);
return app;
}
static removeNode(app: IApp, node: Node): IApp {
app.nodes = app.nodes.filter(n => n.uuid !== node.uuid);
return app;
}
static async addMcpServer(app: IApp, mcpServer: IMcpServer): Promise<IMcpServer[]> {
const result = (
await (await this.db()).execute(
`INSERT INTO apps_mcp_servers (app_id, mcp_server_id) VALUES ($1, $2)`,
[app.id, mcpServer.id],
)
);
if (result.rowsAffected == 1) {
app.mcpServers.push(mcpServer);
return app.mcpServers;
}
throw "AddMcpServerError";
}
static async removeMcpServer(app: IApp, mcpServer: IMcpServer): Promise<IMcpServer[]> {
const result = (
await (await this.db()).execute(
'DELETE FROM apps_mcp_servers WHERE app_id = $1 AND mcp_server_id = $2',
[app.id, mcpServer.id],
)
);
if (result.rowsAffected == 1) {
app.mcpServers = app.mcpServers.filter(m => m.id == mcpServer.id);
return app.mcpServers;
}
throw "RemoveMcpServerError";
}
protected static async fromSql(row: Row): Promise<IApp> {
return {
...row,
interface: Interface[row.interface as keyof typeof Interface],
nodes: JSON.parse(row.nodes),
mcpServers: await McpServer.forApp(row.id),
created: moment.utc(row.created),
modified: moment.utc(row.modified),
}
}
protected static async toSql(app: IApp): Promise<ToSqlRow<Row>> {
return {
name: app.name,
description: app.description,
readme: app.readme,
image: app.image,
interface: app.interface,
nodes: JSON.stringify(app.nodes),
}
}
}

View File

@@ -0,0 +1,439 @@
import Database from "@tauri-apps/plugin-sql";
import { DATABASE_URL } from "$lib/const";
import { info } from "$lib/logger";
// Database connection
export let db: Database;
// SQL rows should never include reserved columns.
export type ToSqlRow<Row> = Omit<Row, 'id' | 'created' | 'modified'>;
// Columns that should never be included in an UPDATE or INSERT query.
export const ReservedColumns = ['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.
//
// 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.
//
// ## `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.
//
// ## Serielization / Deserialization
//
// [De]Serialization is handled through two functions `fromSql` and `toSql`
// that you need to implement on your model.
//
// ### `fromSql`
//
// Converts a database row (`Row`) into an instance (`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`
//
// 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.
//
// `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.
//
// ## Usage
//
// `Model` is a function. It takes two generic types and the name of the table
// records reside within.
//
// ## Example
//
// ```ts
// export interface IMessage {
// userId: string;
// content: string;
// }
//
// interface Row {
// user_id: string;
// content: string;
// }
//
// class Message extends Model<Interface, Row>('messages') {
// static function fromSql(row: Row): Promise<IMessage> {
// return {
// id: row.id,
// userId: row.user_id,
// content: row.content,
// created: moment.utc(row.created),
// modified: moment.utc(row.modified),
// }
// }
//
// static function toSql(message: IMessage): Promise<ToSqlRow<Row>> {
// return {
// user_id: message.rowId,
// content: message.content,
// }
// }
// }
// ```
//
export default function Model<Interface extends Obj, Row extends Obj>(table: string) {
let repo: Interface[] = $state([]);
return class Model {
// Reload records from the database and populate the Repository.
//
static async sync(): Promise<void> {
(await this.query(`SELECT * FROM ${table}`))
.forEach(record => this.syncOne(record));
info(`[green]✔ synced ${table}`);
}
// 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(): Interface {
return {} as Interface;
}
// Does a record with specific params exist.
//
static exists(params: Partial<Interface>): boolean {
return this.findBy(params).length > 0;
}
// Retrieve all records.
//
static all(): Interface[] {
return repo;
}
// Find an individual record by `id`.
//
static find(id: number | string): Interface {
return this.all().find(m => m.id == Number(id)) as Interface;
}
// Find a collection of records by a set of the model's columns.
//
// Note, this expects `params` to match database columns, NOT
// properties of a associated interface.
//
static findBy(params: Partial<Interface>): Interface[] {
return repo.filter(m => {
return Object
.entries(params)
.every(([key, value]) => (
m[key] == value
))
});
}
// Find the last record, by `id`
//
static last(): Interface {
return repo[repo.length - 1];
}
// Update or Create a record.
//
// 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);
}
}
// Create a new record.
//
// You may pass a subset of properties the `Interface` expects and the
// missing properties will be filled via the `default` function.
//
// `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,
)
);
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);
let instance = (await this.query(
`INSERT INTO ${table} (${columns}) VALUES (${binds}) RETURNING *`,
values,
))[0];
this.syncOne(instance);
this.removeEphemeralInstances();
instance = await this.afterCreate(instance);
instance = await this.afterSave(instance);
return instance;
}
// Update a record.
//
// Only pass the columns you intend to change.
//
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;
}
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;
}
// Update, or Add, a single record
//
private static syncOne(record: Interface) {
const existing = this.find(record.id);
if (existing) {
Object.assign(existing, record);
} else {
const reactive = $state(record);
repo.push(reactive);
}
}
// Remove an instance from the repo
//
private static syncRemove(instance: Interface) {
repo = repo.filter(
i => i.id !== instance.id
);
}
// 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
);
}
// 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.
//
protected static async fromSql(_: Row): Promise<Interface> {
throw "NotImplementedError";
}
// Transform an `Interface` object into a `Row` of database compatiable
// values.
//
protected static async toSql(_: ToSqlRow<Interface>): Promise<ToSqlRow<Row>> {
throw "NotImplementedError";
}
// Transform the `Row` object before it's used to generate a query.
//
protected static async beforeSave(row: ToSqlRow<Row>): Promise<ToSqlRow<Row>> {
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>> {
return row;
}
protected static async afterCreate(instance: Interface): Promise<Interface> {
return instance;
}
protected static async beforeUpdate(row: ToSqlRow<Row>): Promise<ToSqlRow<Row>> {
return row;
}
protected static async afterUpdate(instance: Interface): Promise<Interface> {
return instance;
}
}
}

View File

@@ -0,0 +1,97 @@
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;
command: string;
metadata?: Metadata;
}
interface Row {
id: number;
command: string;
metadata: 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 default(): IMcpServer {
return {
command: '',
metadata: {
protocolVersion: '',
capabilities: {
tools: {},
},
serverInfo: {
name: undefined,
version: '',
}
},
}
}
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 name(server: IMcpServer): string {
return server.metadata?.serverInfo.name || 'Unknown';
}
static async start(server: IMcpServer, session: ISession) {
await invoke('start_mcp_server', {
sessionId: session.id,
command: server.command,
});
}
static async stop(server: IMcpServer, session: ISession) {
await invoke('stop_mcp_server', {
sessionId: session.id,
name: McpServer.name(server),
});
}
static async afterCreate(server: IMcpServer): Promise<IMcpServer> {
const metadata: Metadata = JSON.parse(
await invoke('get_metadata', { command: server.command })
);
return await this.update({
...server,
metadata,
});
}
static async fromSql(row: Row): Promise<IMcpServer> {
return {
id: row.id,
command: row.command,
metadata: JSON.parse(row.metadata),
};
}
static async toSql(server: IMcpServer): Promise<ToSqlRow<Row>> {
return {
command: server.command,
metadata: JSON.stringify(server.metadata),
}
}
}

80
src/lib/models/message.ts Normal file
View File

@@ -0,0 +1,80 @@
import moment from "moment";
import type { Role } from '$lib/llm.d';
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: Record<string, any>[]; // eslint-disable-line
sessionId?: number;
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;
created: string;
modified: string;
}
export default class Message extends Model<IMessage, Row>('messages') {
static default(): IMessage {
return {
role: 'user',
content: '',
model: '',
name: '',
toolCalls: [],
}
}
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,
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,
}
}
}

View File

@@ -0,0 +1,44 @@
import * as llm from '$lib/llm';
export type IModel = llm.Model;
export interface Details {
parentModel: string;
format: string;
family: string;
families: string[];
parameter_size: string;
quantization_level: string;
}
let repo: IModel[] = $state([]);
export default class Model {
static async sync(): Promise<void> {
const client = new llm.Client();
const models: IModel[] = await client.list();
repo = await Promise.all(
models.map(async m => {
const model = await client.info(m.name);
return { ...m, ...model }
})
);
}
static default(): IModel {
return repo[0];
}
static find(name: string): IModel {
return repo.find(m => m.name == name) as IModel;
}
static all(): IModel[] {
return repo;
}
static supportsTools(model: IModel): boolean {
return model.capabilities?.includes('tools') == true;
}
}

166
src/lib/models/session.ts Normal file
View File

@@ -0,0 +1,166 @@
import { invoke } from "@tauri-apps/api/core";
import moment from "moment";
import * as llm from '$lib/llm';
import { getMCPTools } from '$lib/mcp';
import App, { type IApp } from '$lib/models/app';
import Model, { type ToSqlRow } from '$lib/models/base.svelte';
import McpServer, { type IMcpServer } from "$lib/models/mcp-server";
import Message, { type IMessage } from "$lib/models/message";
import LLMModel from '$lib/models/model.svelte';
export const DEFAULT_SUMMARY = 'Untitled';
export interface ISession {
id?: number;
appId?: number;
summary: string;
config: {
model: string;
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 Model<ISession, Row>('sessions') {
static default(): ISession {
return {
summary: DEFAULT_SUMMARY,
config: {
model: LLMModel.default().name,
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.findBy({ sessionId: session.id });
}
static async tools(session: ISession): Promise<llm.Tool[]> {
const model = LLMModel.find(session.config.model);
if (!LLMModel.supportsTools(model)) {
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> {
const name = McpServer.name(server);
if (this.hasMcpServer(session, name)) {
return session;
}
session.config.enabledMcpServers.push(name);
return await this.update(session);
}
static async removeMcpServer(session: ISession, server: IMcpServer): Promise<ISession> {
session.config.enabledMcpServers = session
.config
.enabledMcpServers
.filter(s => s !== McpServer.name(server));
return await this.update(session);
}
static async summarize(session: ISession, model: string) {
if (!session.id) {
return;
}
if (!this.hasUserMessages(session) || session.summary !== DEFAULT_SUMMARY) {
return;
}
const client = new llm.Client();
const message: IMessage = await 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.',
name: '',
tool_calls: [],
}
]
);
// 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),
};
}
}

59
src/lib/models/setting.ts Normal file
View File

@@ -0,0 +1,59 @@
import { OLLAMA_URL_CONFIG_KEY } from '$lib/const';
import * as llm from '$lib/llm';
import Model, { type ToSqlRow } from '$lib/models/base.svelte';
import LLMModel from '$lib/models/model.svelte';
export interface ISetting {
id?: number;
display: string;
key: string;
value: unknown;
}
interface Row {
id: number;
display: string;
key: string;
value: string;
}
export default class Setting extends Model<ISetting, Row>('settings') {
static get OllamaUrl(): string {
return (
this.findBy({ key: OLLAMA_URL_CONFIG_KEY })
)[0].value as string;
}
static async validate(setting: ISetting): Promise<boolean> {
if (setting.key == OLLAMA_URL_CONFIG_KEY) {
const client = new llm.Client({ url: setting.value as string })
return await client.connected();
}
return true;
}
protected static async afterUpdate(setting: ISetting): Promise<ISetting> {
if (setting.key == OLLAMA_URL_CONFIG_KEY) {
await LLMModel.sync();
}
return setting;
}
protected static async fromSql(row: Row): Promise<ISetting> {
return {
id: row.id,
display: row.display,
key: row.key,
value: JSON.parse(row.value),
}
}
protected static async toSql(setting: ISetting): Promise<ToSqlRow<Row>> {
return {
display: setting.display,
key: setting.key,
value: JSON.stringify(setting.value),
}
}
}

38
src/lib/startup.ts Normal file
View File

@@ -0,0 +1,38 @@
import { info } from "$lib/logger";
export enum StartupCheck {
Ollama = 'ollama',
Agreement = 'agreement',
MissingModels = 'missing-models',
}
export type Condition = () => Promise<boolean>;
export type OnSuccess = () => Promise<void>;
const checks: Array<[StartupCheck, Condition, OnSuccess?]> = [];
export default {
// Adds a check, if necessary, to continually attempt until success.
//
// Needs to run the `condition` and (optional) `onSuccess` once, immediately,
// in case this is being triggered by an HMR in dev.
//
// During HMR, we reload into whatever the current URL is, meaning we don't
// visit the root route where these checks are normally executed. We need
// them to run here, so that the `onSuccess` functions execute and do
// things like sync the database, load models, etc.
//
async addCheck(check: StartupCheck, condition: Condition, onSuccess?: OnSuccess) {
if (!await condition()) {
info(`[red] startup check failed:[default] ${check}`);
checks.push([check, condition, onSuccess]);
} else {
info(`[green]✔ startup check passed:[default] ${check}`);
await onSuccess?.();
}
},
get checks() {
return checks;
}
}

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

@@ -0,0 +1,14 @@
export enum Interface {
Voice = "Voice",
Chat = "Chat",
Dashboard = "Dashboard",
Daemon = "Daemon",
}
export enum NodeType {
Context = "Context",
}
export interface CheckboxEvent extends Event {
currentTarget: EventTarget & HTMLInputElement;
}

1172
src/markdown.css Normal file

File diff suppressed because it is too large Load Diff

15
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,15 @@
<script lang="ts">
// App-wide event handlers
import closables from '$lib/closables';
const { children } = $props();
async function onWindowClick(e: Event) {
closables.close(e);
}
</script>
<svelte:window onclick={(e) => onWindowClick(e)} />
{@render children?.()}

2
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;

Some files were not shown because too many files have changed in this diff Show More