├── .env.example ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── desktop ├── CHANGELOG.md ├── build │ ├── app-icons │ │ ├── client-icon-linux-128.png │ │ ├── client-icon-linux-16.png │ │ ├── client-icon-linux-256.png │ │ ├── client-icon-linux-32.png │ │ ├── client-icon-linux-48.png │ │ ├── client-icon-linux-512.png │ │ ├── client-icon-linux-64.png │ │ ├── client-icon-macOS.png │ │ ├── client-icon-macOS@2x.png │ │ └── client-icon-windows.ico │ ├── entitlements.mac.plist │ └── notarize.ts ├── index.html ├── package.json ├── src │ ├── ai │ │ ├── ai-models.ts │ │ ├── kernel.ts │ │ └── providers │ │ │ ├── anthropic.ts │ │ │ ├── ollama.ts │ │ │ └── openai.ts │ ├── common │ │ ├── dependencies.ts │ │ ├── disposable.ts │ │ ├── notifier.ts │ │ ├── unternet.ts │ │ └── utils │ │ │ ├── dom.ts │ │ │ ├── http.ts │ │ │ ├── index.ts │ │ │ └── resources.ts │ ├── constants.ts │ ├── deprecated │ │ ├── tabs.ts │ │ ├── workspace-view.css │ │ └── workspace-view.ts │ ├── electron │ │ ├── auto-update.ts │ │ ├── local-applets.ts │ │ ├── main.ts │ │ ├── menu.ts │ │ ├── preload.ts │ │ └── release-notes.ts │ ├── index.ts │ ├── modals │ │ ├── modal-element.ts │ │ ├── modal-service.ts │ │ ├── modal.css │ │ └── modal.ts │ ├── models │ │ ├── config-model.ts │ │ ├── message-model.ts │ │ ├── process-model.ts │ │ ├── resource-model.ts │ │ └── workspace-model.ts │ ├── protocols │ │ ├── applet │ │ │ └── local │ │ │ │ └── protocol.ts │ │ ├── buitin │ │ │ ├── icon-128x128.png │ │ │ ├── protocol.ts │ │ │ └── resources.ts │ │ ├── http │ │ │ ├── processes.ts │ │ │ └── protocol.ts │ │ └── index.ts │ ├── shortcuts │ │ ├── global-shortcuts.ts │ │ └── shortcut-service.ts │ ├── storage │ │ ├── database-service.ts │ │ ├── indexed-db.ts │ │ └── keystore-service.ts │ └── ui │ │ ├── app-root.css │ │ ├── app-root.ts │ │ ├── common │ │ ├── button.css │ │ ├── button.ts │ │ ├── checkbox.ts │ │ ├── combobox.ts │ │ ├── context-menu.ts │ │ ├── icons │ │ │ ├── icon-registry.ts │ │ │ └── icon.ts │ │ ├── input.css │ │ ├── input.ts │ │ ├── label.ts │ │ ├── markdown-text.ts │ │ ├── menu │ │ │ └── native-menu.ts │ │ ├── pages.ts │ │ ├── popover.css │ │ ├── popover.ts │ │ ├── radio.ts │ │ ├── scroll-container.ts │ │ ├── select.ts │ │ ├── styles │ │ │ ├── global.css │ │ │ ├── markdown.css │ │ │ ├── reset.css │ │ │ └── theme.css │ │ └── textarea.ts │ │ ├── modals │ │ ├── bug-modal.ts │ │ ├── new-workspace-modal.ts │ │ ├── settings-modal │ │ │ ├── applets-section.ts │ │ │ ├── global-section.ts │ │ │ ├── index.css │ │ │ ├── index.ts │ │ │ ├── shortcuts-section.ts │ │ │ └── workspace-section.ts │ │ └── workspace-delete-modal.ts │ │ ├── processes │ │ ├── process-frame.css │ │ ├── process-frame.ts │ │ ├── process-view.css │ │ └── process-view.ts │ │ ├── tab-handle.ts │ │ ├── thread │ │ ├── idle-screen.css │ │ ├── idle-screen.ts │ │ ├── thread-view.css │ │ └── thread-view.ts │ │ ├── toolbar │ │ ├── command-bar.css │ │ ├── command-bar.ts │ │ ├── command-input.ts │ │ ├── resource-bar.css │ │ ├── resource-bar.ts │ │ ├── resources-popover.css │ │ ├── resources-popover.ts │ │ ├── workspace-selector.css │ │ └── workspace-selector.ts │ │ └── top-bar │ │ ├── model-selector.ts │ │ ├── top-bar.css │ │ └── top-bar.ts ├── theme-demo.js ├── theme.html ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.notarize.json ├── vite-env.d.ts └── vite.config.js ├── kernel ├── .env.example ├── README.md ├── assets │ └── kernel-schematic.png ├── docs │ ├── interpreter.md │ └── processes.md ├── example │ ├── .env.example │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── commands │ │ ├── file.ts │ │ └── index.ts │ │ ├── index.ts │ │ ├── protocols.ts │ │ ├── resources.ts │ │ └── types.ts ├── package.json ├── src │ ├── index.ts │ ├── interpreter │ │ ├── flags.ts │ │ ├── index.ts │ │ ├── messages.ts │ │ ├── model.ts │ │ ├── prompts.ts │ │ ├── schemas.ts │ │ └── strategies.ts │ ├── response-types.ts │ ├── runtime │ │ ├── actions.ts │ │ ├── index.ts │ │ ├── processes.ts │ │ ├── protocols.ts │ │ └── resources.ts │ └── shared │ │ ├── types.ts │ │ └── utils.ts ├── tests │ ├── common │ │ └── tooling.ts │ ├── fixtures │ │ ├── model.ts │ │ └── sample.png │ ├── interpreter.test.ts │ └── messages.test.ts └── tsconfig.json ├── package-lock.json └── package.json /.env.example: -------------------------------------------------------------------------------- 1 | APP_UNTERNET_API_KEY="" -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | permissions: 8 | contents: write 9 | jobs: 10 | package: 11 | name: Build and Release 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | include: 16 | - os: ubuntu-latest 17 | - os: windows-latest 18 | - os: macos-13 # Intel/x64 runner 19 | mac_arch: x64 20 | - os: macos-14 # Apple Silicon/arm64 runner 21 | mac_arch: arm64 22 | 23 | steps: 24 | - name: Install dependencies (Ubuntu) 25 | if: startsWith(matrix.os, 'ubuntu') 26 | run: sudo apt-get update && sudo apt-get install -y libarchive-tools 27 | 28 | - name: Checkout repo 29 | uses: actions/checkout@v4 30 | with: 31 | submodules: true 32 | 33 | - name: Setup Node.js 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version-file: .nvmrc 37 | cache: 'npm' 38 | 39 | - name: Clean install 40 | run: | 41 | npm install 42 | 43 | - name: Build and release (macOS) 44 | if: startsWith(matrix.os, 'macos') 45 | run: | 46 | npm run build 47 | npx electron-builder --mac --${{ matrix.mac_arch }} --publish always 48 | env: 49 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | CSC_LINK: ${{ secrets.mac_certs }} 51 | CSC_KEY_PASSWORD: ${{ secrets.mac_certs_password }} 52 | APPLE_ID: ${{ secrets.apple_id }} 53 | APPLE_TEAM_ID: ${{ secrets.apple_team_id }} 54 | APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} 55 | APP_UNTERNET_API_KEY: ${{ secrets.APP_UNTERNET_API_KEY }} 56 | 57 | - name: Build and release (Windows/Linux) 58 | if: matrix.os != 'macos-13' && matrix.os != 'macos-14' 59 | uses: samuelmeuli/action-electron-builder@v1 60 | with: 61 | github_token: ${{ secrets.GITHUB_TOKEN }} 62 | release: true 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | **/*/node_modules 4 | **/*/dist 5 | .DS_Store 6 | **/.DS_Store 7 | .vscode/ 8 | dist 9 | release/ 10 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix= -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.14.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.4 2 | 3 | - Add ability to attach files to interaction inputs & translate to ai message 4 | 5 | ## 0.3.3 6 | 7 | - Add a cohesive theme 8 | 9 | ## 0.3.2 10 | 11 | - fix/remove-db-service 12 | 13 | This file is unused. 14 | 15 | ## 0.3.1 16 | 17 | - regen lockfile (#95) 18 | 19 | Co-authored-by: Vinay Raghu 20 | 21 | ## 0.3.0 22 | 23 | - Merge branch 'main' into feat/tagging 24 | 25 | ## 0.2.0 26 | 27 | - fix todos 28 | 29 | ## Changes prior to changelog introduction 30 | 31 | - Change build system & folder structure to build entire app & html together (not have a separate web server) 32 | - Add model layer, for efficient in-memory storage, and separate from persistence services 33 | - Make Kernel truly modular & without dependence on the main app code 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Operator 2 | 3 | An experimental client for the web from [Unternet](https://unternet.co). 4 | 5 | ## Setup 6 | 7 | - Run `npm install` 8 | - Copy `.env.example` to `.env` and fill in the required environment variables 9 | - Start the native client with `npm run dev` 10 | 11 | ## Local Models 12 | 13 | Operator has support for running LLM inference locally using [Ollama](https://ollama.com/). 14 | By default, this will be used if no Vite OpenAI API key has been provided as an environment variable. 15 | 16 | To set this up on Linux use this to download and install Ollama: 17 | 18 | ```bash 19 | curl -fsSL https://ollama.com/install.sh | sh 20 | ``` 21 | 22 | Or download the binary from [their webisite](https://ollama.com/download). 23 | 24 | Once installed use this to download the qwen2.5-coder:3b model which is the default. 25 | 26 | ```bash 27 | ollama run qwen2.5-coder:3b 28 | ``` 29 | 30 | We have tested it with the following and mistral seems to work best. Please let us know if you have tried others that work well / don't work so we can update this table. 31 | 32 | | Model | Search | Error | 33 | | ------------------ | ---------------------------------------------------- | -------------------------------------------------------------------- | 34 | | deepseek-r1:latest | use @web to search for weather 02155 | Error handling command input: AI_APICallError: Invalid JSON response | 35 | | mistral | use @Web to get me top 5 news stories about FB stock | ✅ | 36 | | qwen2.5-coder:3b | use @Web search for top news in india | 🟠 executes the search but does not add a response | 37 | 38 | ## Builds 39 | 40 | ### Windows 41 | 42 | Windows builds are currently unsigned. Signing can be enabled by adding `windows-certs.pfx` and setting the `WINDOWS_CERTS_PASSWORD` secret in GitHub Actions and updating the `certificateFile` and `certificatePassword` fields in `package.json` under the `"win"` section. 43 | 44 | ## Unternet API 45 | 46 | You can optionally add the Unternet API to enable features like web search. In order to do this, add your Unternet API key (don't have one? email us!). For now, this will only work when you build as the API is expected to be local. 47 | -------------------------------------------------------------------------------- /desktop/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.0 2 | 3 | - { 4 | "compilerOptions": { 5 | "target": "ES2020", 6 | "module": "commonjs", 7 | "lib": ["ES2020", "DOM"], 8 | "rootDir": "src", 9 | "outDir": "dist", 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true 13 | }, 14 | "include": ["src/electron/**/*"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-linux-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-linux-128.png -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-linux-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-linux-16.png -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-linux-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-linux-256.png -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-linux-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-linux-32.png -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-linux-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-linux-48.png -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-linux-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-linux-512.png -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-linux-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-linux-64.png -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-macOS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-macOS.png -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-macOS@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-macOS@2x.png -------------------------------------------------------------------------------- /desktop/build/app-icons/client-icon-windows.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/build/app-icons/client-icon-windows.ico -------------------------------------------------------------------------------- /desktop/build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | com.apple.security.cs.allow-jit 7 | 8 | com.apple.security.cs.allow-unsigned-executable-memory 9 | 10 | com.apple.security.cs.debugger 11 | 12 | 13 | -------------------------------------------------------------------------------- /desktop/build/notarize.ts: -------------------------------------------------------------------------------- 1 | import type { AfterPackContext } from 'electron-builder'; 2 | 3 | export default async function notarizing(context: AfterPackContext) { 4 | const { electronPlatformName, appOutDir } = context; 5 | 6 | if (electronPlatformName !== 'darwin') return; 7 | 8 | if ( 9 | !process.env.APPLE_ID || 10 | !process.env.APPLE_APP_SPECIFIC_PASSWORD || 11 | !process.env.APPLE_TEAM_ID 12 | ) { 13 | console.warn('[notarize] Skipping: missing Apple credentials'); 14 | return; 15 | } 16 | 17 | const appName = context.packager.appInfo.productFilename; 18 | 19 | const { notarize } = await import('@electron/notarize'); 20 | 21 | return notarize({ 22 | appPath: `${appOutDir}/${appName}.app`, 23 | appleId: process.env.APPLE_ID, 24 | appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, 25 | teamId: process.env.APPLE_TEAM_ID, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /desktop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Unternet Client 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /desktop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unternet/client-desktop", 3 | "version": "0.10.3", 4 | "homepage": "https://unternet.co", 5 | "license": "ISC", 6 | "main": "dist/electron/main.js", 7 | "scripts": { 8 | "build": "npm run build:notarize && vite build && npm run build:electron && npm run build:icons", 9 | "build:notarize": "tsc -p tsconfig.notarize.json", 10 | "build:icons": "shx mkdir -p dist/electron/app-icons && shx cp -r build/app-icons/* dist/electron/app-icons/ && shx cp build/app-icons/client-icon-linux-512.png dist/electron/app-icons/client-icon-linux.png", 11 | "build:web": "vite build", 12 | "build:electron": "tsc --project tsconfig.node.json", 13 | "dev": "concurrently 'npm run dev:web' 'npm run dev:electron' ", 14 | "dev:electron": "npm run build:electron && npm run build:icons && electron .", 15 | "dev:web": "vite" 16 | }, 17 | "dependencies": { 18 | "@lucide/lab": "^0.1.2", 19 | "@unternet/kernel": "*", 20 | "@unternet/sdk": "^0.2.1", 21 | "@web-applets/sdk": "^0.2.6", 22 | "dexie": "^4.0.11", 23 | "electron-is-dev": "^3.0.1", 24 | "electron-log": "^5.0.0-beta.16", 25 | "electron-updater": "^6.6.2", 26 | "immer": "^10.1.1", 27 | "lit": "^3.2.1", 28 | "lucide": "^0.484.0", 29 | "marked": "^15.0.7", 30 | "mime": "^2.6.0", 31 | "openai": "^4.91.1", 32 | "pluralize": "^8.0.0", 33 | "ulid": "^2.4.0", 34 | "uri-js": "^4.4.1" 35 | }, 36 | "devDependencies": { 37 | "@types/mime": "^3.0.4", 38 | "concurrently": "^9.1.2", 39 | "dotenv": "^16.4.7", 40 | "electron": "^35.4.0", 41 | "vite": "^6.2.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /desktop/src/ai/ai-models.ts: -------------------------------------------------------------------------------- 1 | import { LanguageModel } from '@unternet/kernel'; 2 | import { OpenAIModelProvider } from './providers/openai'; 3 | import { OllamaModelProvider } from './providers/ollama'; 4 | 5 | export interface ConfigValidationResult { 6 | valid: boolean; 7 | error?: string; 8 | } 9 | 10 | export interface AIModelProvider { 11 | getAvailableModels( 12 | providerConfig: AIModelProviderConfig 13 | ): Promise; 14 | getModel( 15 | modelId: string, 16 | providerConfig: AIModelProviderConfig 17 | ): Promise; 18 | validateConfig( 19 | providerConfig: AIModelProviderConfig 20 | ): Promise; 21 | } 22 | 23 | export const AIModelProviderNames = { 24 | openai: 'OpenAI', 25 | ollama: 'Ollama', 26 | // anthropic: 'Anthropic', // TODO: Add Anthropic support 27 | }; 28 | 29 | export type AIModelProviderName = keyof typeof AIModelProviderNames; 30 | 31 | export interface AIModelDescriptor { 32 | name: string; 33 | provider: AIModelProviderName; 34 | description?: string; 35 | } 36 | 37 | export interface AIModelProviderConfig { 38 | apiKey?: string; 39 | baseUrl?: string; 40 | } 41 | 42 | type AIModelProviderMap = { [key in AIModelProviderName]: AIModelProvider }; 43 | 44 | export class AIModelService { 45 | private providers: AIModelProviderMap; 46 | // Restrict to a subset of models for now 47 | private allowedOpenAiModels: Array = [ 48 | 'gpt-4o-mini', 49 | 'gpt-4-turbo', 50 | 'gpt-3.5-turbo', 51 | 'gpt-4o', 52 | ]; 53 | 54 | constructor(providers: AIModelProviderMap) { 55 | this.providers = providers; 56 | } 57 | 58 | async getAvailableModels( 59 | providerName: AIModelProviderName, 60 | providerConfig: AIModelProviderConfig 61 | ): Promise { 62 | if (!(providerName in this.providers)) { 63 | throw new Error(`Provider name '${providerName}' not valid.`); 64 | } 65 | return this.providers[providerName].getAvailableModels(providerConfig); 66 | } 67 | 68 | getModel( 69 | providerName: AIModelProviderName, 70 | modelId: string, 71 | providerConfig: AIModelProviderConfig 72 | ) { 73 | if (!(providerName in this.providers)) { 74 | throw new Error(`Provider name '${providerName}' not valid.`); 75 | } 76 | return this.providers[providerName].getModel(modelId, providerConfig); 77 | } 78 | 79 | async validateProviderConfig( 80 | providerName: AIModelProviderName, 81 | providerConfig: AIModelProviderConfig 82 | ): Promise { 83 | if (!(providerName in this.providers)) { 84 | return { 85 | valid: false, 86 | error: `Provider name '${providerName}' not valid.`, 87 | }; 88 | } 89 | return this.providers[providerName].validateConfig(providerConfig); 90 | } 91 | 92 | getAllowedOpenAiModels() { 93 | return this.allowedOpenAiModels; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /desktop/src/ai/providers/anthropic.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/src/ai/providers/anthropic.ts -------------------------------------------------------------------------------- /desktop/src/ai/providers/ollama.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AIModelDescriptor, 3 | AIModelProvider, 4 | AIModelProviderConfig, 5 | ConfigValidationResult, 6 | } from '../ai-models'; 7 | import { createOllama } from 'ollama-ai-provider'; 8 | 9 | export const OLLAMA_BASE_URL = 'http://localhost:11434/api'; 10 | 11 | export interface OllamaModelDetails { 12 | format: string; 13 | family: string; 14 | families: string[] | null; 15 | parameter_size: string; 16 | quantization_level: string; 17 | } 18 | 19 | export interface OllamaModel { 20 | name: string; 21 | modified_at: string; 22 | size: number; 23 | digest: string; 24 | details: OllamaModelDetails; 25 | } 26 | 27 | export interface OllamaTagsResponse { 28 | models: OllamaModel[]; 29 | } 30 | 31 | export class OllamaModelProvider implements AIModelProvider { 32 | async getAvailableModels({ 33 | baseUrl, 34 | }: AIModelProviderConfig): Promise { 35 | baseUrl = baseUrl || OLLAMA_BASE_URL; 36 | 37 | try { 38 | const response = await fetch(`${baseUrl}/tags`, { 39 | method: 'GET', 40 | headers: { 'Content-Type': 'application/json' }, 41 | }); 42 | 43 | if (!response.ok) { 44 | throw new Error(`Failed to fetch models: ${response.statusText}`); 45 | } 46 | 47 | const { models } = (await response.json()) as OllamaTagsResponse; 48 | 49 | return models.map((model) => ({ 50 | name: model.name, 51 | provider: 'ollama', 52 | })); 53 | } catch (error) { 54 | console.error('Error fetching Ollama models:', error); 55 | throw error; 56 | } 57 | } 58 | 59 | async getModel( 60 | modelId: string, 61 | providerConfig: AIModelProviderConfig 62 | ): Promise { 63 | const model = createOllama({ 64 | baseURL: providerConfig.baseUrl || OLLAMA_BASE_URL, 65 | })(modelId); 66 | return model; 67 | } 68 | 69 | async validateConfig( 70 | providerConfig: AIModelProviderConfig 71 | ): Promise { 72 | // Ollama doesn't require an API key, but it does need a base URL 73 | // If no baseUrl is provided, we'll use the default 74 | return { valid: true }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /desktop/src/ai/providers/openai.ts: -------------------------------------------------------------------------------- 1 | import { LanguageModel } from '@unternet/kernel'; 2 | import { 3 | AIModelDescriptor, 4 | AIModelProvider, 5 | AIModelProviderConfig, 6 | ConfigValidationResult, 7 | } from '../ai-models'; 8 | import { OpenAI } from 'openai'; 9 | import { createOpenAI } from '@ai-sdk/openai'; 10 | 11 | const OPENAI_MODEL_EXCLUDE_PATTERNS = [ 12 | 'whisper', 13 | 'tts', 14 | 'dall-e', 15 | 'embedding', 16 | 'moderation', 17 | ]; 18 | 19 | export class OpenAIModelProvider implements AIModelProvider { 20 | async getAvailableModels( 21 | providerConfig: AIModelProviderConfig 22 | ): Promise { 23 | if (!providerConfig.apiKey) { 24 | throw new Error('OpenAI API Key is missing or empty.'); 25 | } 26 | 27 | try { 28 | const client = new OpenAI({ 29 | apiKey: providerConfig.apiKey, 30 | dangerouslyAllowBrowser: true, 31 | }); 32 | 33 | const { data: models } = await client.models.list(); 34 | 35 | function modelFilter(model: OpenAI.Models.Model) { 36 | const shouldExclude = OPENAI_MODEL_EXCLUDE_PATTERNS.some((pattern) => 37 | model.id.toLowerCase().includes(pattern) 38 | ); 39 | return !shouldExclude; 40 | } 41 | 42 | return models.filter(modelFilter).map((model) => ({ 43 | name: model.id, 44 | provider: 'openai', 45 | })); 46 | } catch (error) { 47 | throw error; 48 | } 49 | } 50 | 51 | async getModel( 52 | modelId: string, 53 | providerConfig: AIModelProviderConfig 54 | ): Promise { 55 | return createOpenAI({ 56 | apiKey: providerConfig.apiKey, 57 | compatibility: 'strict', 58 | })(modelId); 59 | } 60 | 61 | async validateConfig( 62 | providerConfig: AIModelProviderConfig 63 | ): Promise { 64 | if (!providerConfig.apiKey) { 65 | return { valid: false, error: 'OpenAI API Key is required' }; 66 | } 67 | return { valid: true }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /desktop/src/common/dependencies.ts: -------------------------------------------------------------------------------- 1 | export class DependencyContainer { 2 | private singletons = new Map(); 3 | 4 | registerSingleton(name: string, instance: T): void { 5 | const token = name; 6 | this.singletons.set(token, instance); 7 | } 8 | 9 | resolve(token: string): T { 10 | const instance = this.singletons.get(token); 11 | if (!instance) 12 | throw new Error(`No singleton registered for token: ${String(token)}`); 13 | return instance; 14 | } 15 | } 16 | 17 | export const dependencies = new DependencyContainer(); 18 | -------------------------------------------------------------------------------- /desktop/src/common/disposable.ts: -------------------------------------------------------------------------------- 1 | export interface IDisposable { 2 | disposed: boolean; 3 | dispose(): void; 4 | } 5 | 6 | export class Disposable { 7 | disposed = false; 8 | disposables: DisposableGroup; 9 | private disposeCallback: () => void; 10 | 11 | constructor(disposeCallback?: () => void) { 12 | if (disposeCallback) this.disposeCallback = disposeCallback; 13 | } 14 | 15 | static createEventListener( 16 | target: EventTarget, 17 | type: string, 18 | listener: EventListener 19 | ) { 20 | target.addEventListener(type, listener); 21 | return new Disposable(() => target.removeEventListener(type, listener)); 22 | } 23 | 24 | dispose(): void { 25 | if (this.disposeCallback) this.disposeCallback(); 26 | this.disposed = true; 27 | } 28 | } 29 | 30 | export class DisposableGroup { 31 | private disposables: IDisposable[] = []; 32 | 33 | add(disposable: Disposable) { 34 | this.disposables.push(disposable); 35 | } 36 | 37 | attachListener( 38 | target: EventTarget, 39 | type: string, 40 | listener: (e: any) => void 41 | ) { 42 | this.add(Disposable.createEventListener(target, type, listener)); 43 | } 44 | 45 | dispose() { 46 | for (const disposable of this.disposables) { 47 | disposable.dispose(); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /desktop/src/common/notifier.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, IDisposable } from './disposable'; 2 | 3 | export class Notifier implements IDisposable { 4 | private subscribers: ((notification?: Notification) => void)[] = []; 5 | private defaultNotificationGetter?: () => Notification; 6 | disposed = false; 7 | 8 | constructor(defaultNotificationGetter?: () => Notification) { 9 | this.defaultNotificationGetter = defaultNotificationGetter; 10 | } 11 | 12 | readonly subscribe = (subscriber: (notification?: Notification) => void) => { 13 | if (this.disposed) { 14 | throw new Error('Emitter is disposed'); 15 | } 16 | 17 | this.subscribers.push(subscriber); 18 | this.onSubscribe(subscriber); 19 | return new Disposable(() => this.removeSubscriber(subscriber)); 20 | }; 21 | 22 | onSubscribe(subscriber: (notification?: Notification) => void) { 23 | if (this.defaultNotificationGetter) { 24 | subscriber(this.defaultNotificationGetter()); 25 | } 26 | } 27 | 28 | notify(notification?: Notification): void { 29 | if (this.disposed) { 30 | throw new Error('Cannot notify, Notifier is disposed'); 31 | } 32 | 33 | for (const subscriber of this.subscribers) { 34 | if (!notification && this.defaultNotificationGetter) { 35 | subscriber(this.defaultNotificationGetter()); 36 | } else { 37 | subscriber(notification); 38 | } 39 | } 40 | } 41 | 42 | private removeSubscriber( 43 | subscriber: (notification?: Notification) => void 44 | ): void { 45 | this.subscribers = this.subscribers.filter((l) => l !== subscriber); 46 | } 47 | 48 | dispose(): void { 49 | this.disposed = true; 50 | this.subscribers = []; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /desktop/src/common/unternet.ts: -------------------------------------------------------------------------------- 1 | import Unternet from '@unternet/sdk'; 2 | 3 | export const unternet = new Unternet({ 4 | apiKey: import.meta.env.APP_UNTERNET_API_KEY, 5 | isDev: import.meta.env.DEV, 6 | }); 7 | -------------------------------------------------------------------------------- /desktop/src/common/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import { HTMLTemplateResult, render } from 'lit'; 2 | 3 | export function appendEl(parent: Node, child: HTMLElement) { 4 | parent.appendChild(child); 5 | return child; 6 | } 7 | 8 | export function clearNode(el: HTMLElement) { 9 | el.innerHTML = ''; 10 | } 11 | 12 | export function attachStyles(shadow: ShadowRoot, styles: string) { 13 | const sheet = new CSSStyleSheet(); 14 | sheet.replaceSync(styles); 15 | shadow.adoptedStyleSheets = [sheet]; 16 | } 17 | 18 | export function declareEl(t: HTMLTemplateResult): T { 19 | const fragment = document.createDocumentFragment(); 20 | render(t, fragment); 21 | const el = fragment.firstChild as T; 22 | return el; 23 | } 24 | 25 | export function createEl( 26 | name: string, 27 | properties: Record = {}, 28 | ...children: (string | Node)[] 29 | ): T { 30 | const element = document.createElement(name) as T; 31 | if (properties) Object.assign(element, properties); 32 | 33 | children.forEach((child) => { 34 | if (typeof child === 'string') { 35 | element.appendChild(document.createTextNode(child)); 36 | } else if (child instanceof Node) { 37 | element.appendChild(child); 38 | } 39 | }); 40 | 41 | return element; 42 | } 43 | 44 | export function createFragment(...children: (string | Node)[]) { 45 | const fragment = document.createDocumentFragment(); 46 | 47 | children.forEach((child) => { 48 | if (typeof child === 'string') { 49 | fragment.appendChild(document.createTextNode(child)); 50 | } else if (child instanceof Node) { 51 | fragment.appendChild(child); 52 | } 53 | }); 54 | 55 | return fragment; 56 | } 57 | -------------------------------------------------------------------------------- /desktop/src/common/utils/http.ts: -------------------------------------------------------------------------------- 1 | import { ActionDict, ResourceIcon } from '@unternet/kernel'; 2 | 3 | export function uriWithScheme( 4 | url: string, 5 | defaultProtocol: 'http' | 'https' = 'https' 6 | ) { 7 | if (url.includes('localhost') && !defaultProtocol) uriWithScheme(url, 'http'); 8 | 9 | try { 10 | const hasProtocol = /^[a-zA-Z]+:\/\//.test(url); 11 | return hasProtocol ? url : `${defaultProtocol}://${url}`; 12 | } catch (error) { 13 | return null; 14 | } 15 | } 16 | 17 | interface WebsiteMetadata { 18 | title: string; 19 | name: string; 20 | short_name: string; 21 | description: string; 22 | icons: ResourceIcon[]; 23 | actions: ActionDict; 24 | } 25 | 26 | export async function getMetadata(url: string): Promise { 27 | let metadata = {} as WebsiteMetadata; 28 | 29 | url = new URL(url).href; 30 | 31 | const fetchFn = (uri: string) => { 32 | const [scheme] = uri.split(':', 1); 33 | 34 | switch (scheme) { 35 | case 'applet+local': 36 | return fetch(uri).then((r) => r.text()); 37 | default: 38 | return system.fetch(uri); 39 | } 40 | }; 41 | 42 | const html = await fetchFn(url); 43 | const parser = new DOMParser(); 44 | const dom = parser.parseFromString(html, 'text/html'); 45 | const manifestLink = dom.querySelector( 46 | 'link[rel="manifest"]' 47 | ) as HTMLLinkElement; 48 | 49 | metadata.title = dom.querySelector('title')?.innerText; 50 | 51 | if (manifestLink) { 52 | const baseUrl = new URL(url); 53 | const manifestUrl = new URL(manifestLink.getAttribute('href'), baseUrl) 54 | .href; 55 | const manifestText = await fetchFn(manifestUrl); 56 | 57 | if (manifestText) { 58 | const manifest = JSON.parse(manifestText); 59 | metadata = manifest; 60 | if (manifest.icons) { 61 | metadata.icons = manifest.icons.map((icon) => { 62 | icon.src = new URL(icon.src, manifestUrl).href; 63 | return icon; 64 | }); 65 | } 66 | } 67 | } 68 | 69 | if (!metadata.name) { 70 | const metaAppName = dom.querySelector( 71 | 'meta[name="application-name"]' 72 | ) as HTMLMetaElement; 73 | if (metaAppName) { 74 | metadata.name = metaAppName.content; 75 | } else { 76 | const title = dom.querySelector('title')?.innerText ?? ''; 77 | metadata.name = title.split(' - ')[0].split(' | ')[0]; 78 | } 79 | } 80 | 81 | if (!metadata.icons) { 82 | const faviconLink = dom.querySelector( 83 | 'link[rel~="icon"]' 84 | ) as HTMLLinkElement; 85 | if (faviconLink) 86 | metadata.icons = [ 87 | { src: new URL(faviconLink.getAttribute('href'), url).href }, 88 | ]; 89 | } 90 | 91 | if (!metadata.description) { 92 | metadata.description = dom 93 | .querySelector('meta[name="description"]') 94 | ?.getAttribute('content'); 95 | } 96 | 97 | if (!metadata.title) { 98 | metadata.title = metadata.name; 99 | } 100 | 101 | return metadata; 102 | // dom.querySelector('meta[name="description"]').getAttribute('content'); 103 | } 104 | -------------------------------------------------------------------------------- /desktop/src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { ProcessContainer, Resource } from '@unternet/kernel'; 2 | 3 | export * from './dom'; 4 | 5 | export function formatTimestamp(timestamp: number): string { 6 | if (!timestamp) return 'Unknown'; 7 | 8 | const date = new Date(timestamp); 9 | const now = new Date(); 10 | const diffMs = now.getTime() - date.getTime(); 11 | const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); 12 | 13 | if (diffDays === 0) { 14 | // Today - show time 15 | return `Today at ${date.toLocaleTimeString([], { 16 | hour: '2-digit', 17 | minute: '2-digit', 18 | })}`; 19 | } else if (diffDays === 1) { 20 | return 'Yesterday'; 21 | } else if (diffDays < 7) { 22 | return `${diffDays} days ago`; 23 | } else { 24 | return date.toLocaleDateString(); 25 | } 26 | } 27 | 28 | export function getResourceIcon(target: Resource | ProcessContainer) { 29 | return target.icons && target.icons[0] && target.icons[0].src; 30 | } 31 | -------------------------------------------------------------------------------- /desktop/src/common/utils/resources.ts: -------------------------------------------------------------------------------- 1 | import { Resource } from '@unternet/kernel'; 2 | 3 | import { ResourceModel } from '../../models/resource-model'; 4 | import { WorkspaceModel } from '../../models/workspace-model'; 5 | 6 | export function enabledResources( 7 | resourceModel: ResourceModel, 8 | workspaceModel: WorkspaceModel 9 | ): Array { 10 | const workspace = workspaceModel.activeWorkspace; 11 | if (!workspace) return []; 12 | 13 | return resourceModel 14 | .all() 15 | .filter((resource) => workspace.resources[resource.uri]?.enabled ?? false); 16 | } 17 | -------------------------------------------------------------------------------- /desktop/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_WORKSPACE_NAME = 'Home'; 2 | export const BUG_REPORT_URL = 3 | 'https://tables.unternet.co/form/8_druzT169biYoHe_k02WE6NNEX0tay_ZRyAvLmkhsg'; 4 | export const NUM_CONCURRENT_PROCESSES = 50; 5 | export const MAX_ACTIVE_MESSAGES = 50; 6 | -------------------------------------------------------------------------------- /desktop/src/deprecated/workspace-view.css: -------------------------------------------------------------------------------- 1 | workspace-view { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | 8 | workspace-view .workspace-content { 9 | position: relative; 10 | flex-grow: 1; 11 | background: var(--color-bg-page); 12 | display: flex; 13 | justify-content: center; 14 | overflow: hidden; 15 | } 16 | 17 | .bottom-bar { 18 | position: relative; 19 | background: var(--color-bg-chrome); 20 | border-top: 1px solid var(--color-border); 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | color: var(--color-text-muted); 25 | } 26 | 27 | .bottom-bar .archive-button { 28 | flex-shrink: 0; 29 | } 30 | 31 | .command-bar { 32 | display: flex; 33 | align-items: center; 34 | justify-content: space-between; 35 | padding: var(--space-4) var(--space-4); 36 | gap: var(--space-2); 37 | width: 100%; 38 | } 39 | 40 | resource-bar { 41 | align-self: stretch; 42 | } 43 | 44 | .tools-menu { 45 | position: absolute; 46 | bottom: 100%; 47 | width: 72ch; 48 | padding: var(--space-2); 49 | border: 1px solid var(--color-border); 50 | } 51 | 52 | .tools-menu.visible { 53 | display: block; 54 | } 55 | .tools-menu.hidden { 56 | display: none; 57 | } 58 | -------------------------------------------------------------------------------- /desktop/src/electron/auto-update.ts: -------------------------------------------------------------------------------- 1 | import { app, dialog, MessageBoxOptions } from 'electron'; 2 | import { autoUpdater } from 'electron-updater'; 3 | import log from 'electron-log'; 4 | 5 | const isDev = !app.isPackaged; 6 | const AUTOUPDATE_INTERVAL = 3_600_000; // 60 * 60 * 1000 7 | 8 | /* === LOGGING === */ 9 | 10 | autoUpdater.logger = log; 11 | 12 | /* === SETUP === */ 13 | 14 | export function setup() { 15 | if (isDev) { 16 | return; 17 | } 18 | 19 | autoUpdater.setFeedURL({ 20 | provider: 'github', 21 | owner: 'unternet-co', 22 | repo: 'client', 23 | }); 24 | 25 | // Check for updates 26 | autoUpdater.on('update-downloaded', (info) => { 27 | const releaseNotes = 28 | typeof info.releaseNotes === 'string' 29 | ? info.releaseNotes 30 | : Array.isArray(info.releaseNotes) 31 | ? info.releaseNotes.map((note) => note.note).join('\n\n') 32 | : ''; 33 | 34 | const dialogOpts: MessageBoxOptions = { 35 | type: 'info', 36 | buttons: ['Restart', 'Later'], 37 | title: 'Application Update', 38 | message: process.platform === 'win32' ? releaseNotes : info.releaseName, 39 | detail: 40 | 'A new version has been downloaded. Restart the application to apply the updates.', 41 | }; 42 | 43 | dialog.showMessageBox(dialogOpts).then((returnValue) => { 44 | if (returnValue.response === 0) autoUpdater.quitAndInstall(); 45 | }); 46 | }); 47 | 48 | autoUpdater.on('error', (error) => { 49 | log.error('Error in auto-updater:', error); 50 | }); 51 | 52 | // Store the interval ID so we can clear it if needed 53 | let autoUpdateIntervalId: NodeJS.Timeout | null = null; 54 | 55 | // Check for updates every hour 56 | autoUpdateIntervalId = setInterval(() => { 57 | autoUpdater.checkForUpdates(); 58 | }, AUTOUPDATE_INTERVAL); 59 | 60 | // Initial check 61 | autoUpdater.checkForUpdates(); 62 | } 63 | -------------------------------------------------------------------------------- /desktop/src/electron/local-applets.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, protocol, shell } from 'electron'; 2 | import FS from 'node:fs'; 3 | import Path from 'node:path'; 4 | import * as URI from 'uri-js'; 5 | import mime from 'mime'; 6 | 7 | export const APPLETS_DIR = `${app.getPath('userData')}/applets/`; 8 | export const SCHEME = 'applet+local'; 9 | 10 | /* === SETUP === */ 11 | 12 | protocol.registerSchemesAsPrivileged([ 13 | { 14 | scheme: SCHEME, 15 | privileges: { 16 | bypassCSP: true, 17 | standard: true, 18 | secure: true, 19 | supportFetchAPI: true, 20 | stream: true, 21 | }, 22 | }, 23 | ]); 24 | 25 | export function setup() { 26 | protocol.handle(SCHEME, async (request) => { 27 | const uri = URI.parse(request.url); 28 | 29 | let path = Path.join(uri.host, uri.path); 30 | if (path.endsWith('/')) { 31 | return new Response(null, { 32 | headers: new Headers([['Location', uri.path + 'index.html']]), 33 | status: 308, 34 | }); 35 | } 36 | 37 | path = Path.join(APPLETS_DIR, path); 38 | 39 | const blob = await FS.openAsBlob(path); 40 | const typ = mime.getType(path); 41 | 42 | return new Response(blob.stream(), { 43 | headers: new Headers([['Content-Type', typ]]), 44 | }); 45 | }); 46 | } 47 | 48 | /* === DIR === */ 49 | 50 | ipcMain.handle('local-applets-dir', async (event) => { 51 | return APPLETS_DIR; 52 | }); 53 | 54 | ipcMain.handle('open-local-applets-dir', async (event) => { 55 | ensureAppletsDir(); 56 | 57 | await shell.openPath(APPLETS_DIR); 58 | }); 59 | 60 | /* === LIST === */ 61 | 62 | const IGNORED_DIRS = ['node_modules', 'src']; 63 | 64 | ipcMain.handle('list-local-applets', async (event) => { 65 | console.log('Loading local applets from: ' + APPLETS_DIR); 66 | 67 | ensureAppletsDir(); 68 | 69 | const entries = FS.readdirSync(APPLETS_DIR, { withFileTypes: true }); 70 | 71 | return entries 72 | .filter((e) => e.isDirectory() || e.isSymbolicLink()) 73 | .flatMap((e) => findManifests([e.name])) 74 | .filter((a) => typeof a === 'string'); 75 | }); 76 | 77 | function findManifests(path: string[]): Array { 78 | const dir = Path.join(APPLETS_DIR, ...path); 79 | const indexHtmlExists = FS.existsSync(Path.join(dir, 'index.html')); 80 | 81 | // Nested items 82 | const items = FS.readdirSync(dir, { 83 | withFileTypes: true, 84 | }) 85 | .flatMap((entry) => { 86 | if (!entry.isDirectory() || IGNORED_DIRS.includes(entry.name)) return []; 87 | return findManifests([...path, entry.name]); 88 | }) 89 | .filter((a) => typeof a === 'string'); 90 | 91 | if (indexHtmlExists) { 92 | const indexFile = FS.readFileSync(Path.join(dir, 'index.html'), { 93 | encoding: 'utf8', 94 | }); 95 | 96 | const refersToManifest = indexFile.includes(' 34 | ${title ?? this.options.title} 35 | 41 | 42 | `; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /desktop/src/modals/modal.css: -------------------------------------------------------------------------------- 1 | .modal-container::backdrop { 2 | background: var(--color-overlay); 3 | backdrop-filter: blur(20px); 4 | opacity: 0; 5 | transition: opacity 200ms ease-out; 6 | } 7 | 8 | .modal-container[open]::backdrop { 9 | opacity: 0.5; 10 | } 11 | 12 | .modal-container { 13 | padding: 0; 14 | color: var(--color-text-default); 15 | min-width: 320px; 16 | background: var(--color-bg-content); 17 | border-radius: var(--rounded-lg); 18 | box-shadow: var(--shadow); 19 | border-width: 1px; 20 | border-color: var(--color-border-default); 21 | border-top-color: color-mix( 22 | in srgb, 23 | var(--color-border-default) 100%, 24 | var(--color-grey-0) 35% 25 | ); 26 | border-bottom-color: color-mix( 27 | in srgb, 28 | var(--color-border-default) 100%, 29 | var(--color-grey-1000) 10% 30 | ); 31 | max-width: 50%; 32 | max-height: 85%; 33 | overflow: hidden; 34 | outline: none; 35 | } 36 | 37 | /* Animations */ 38 | @keyframes modal-fade-up { 39 | from { 40 | opacity: 0; 41 | transform: translateY(32px); 42 | } 43 | to { 44 | opacity: 1; 45 | transform: translateY(0); 46 | } 47 | } 48 | 49 | @keyframes modal-slide-in-right { 50 | from { 51 | transform: translateX(320px); 52 | } 53 | to { 54 | transform: translateX(0); 55 | } 56 | } 57 | 58 | .modal-container[data-position='center'][open], 59 | .modal-container[data-position='full'][open] { 60 | animation: modal-fade-up 200ms ease-out; 61 | } 62 | 63 | /* Right modal animation */ 64 | .modal-container[data-position='right'][open] { 65 | animation: modal-slide-in-right 150ms ease-out; 66 | } 67 | 68 | /* Right-positioned modal variant */ 69 | .modal-container[data-position='right'] { 70 | position: fixed; 71 | top: 0; 72 | right: 0; 73 | bottom: 0; 74 | left: auto; 75 | height: 100vh; 76 | width: 420px; 77 | max-width: 100vw; 78 | max-height: 100vh; 79 | border-radius: 0; 80 | box-shadow: -4px 0 24px 0 rgba(0, 0, 0, 0.14); 81 | border-left: 1px solid var(--color-border-default, #e0e0e0); 82 | border-top: none; 83 | border-bottom: none; 84 | border-right: none; 85 | background: var(--color-bg-content, #fff); 86 | z-index: 400; 87 | padding: 0; 88 | overflow-y: auto; 89 | } 90 | 91 | .modal-header { 92 | position: sticky; 93 | top: 0; 94 | background: var(--color-neutral-50); 95 | color: var(--color-text-muted); 96 | padding: var(--space-2) var(--space-4) var(--space-2) var(--space-6); 97 | display: flex; 98 | text-transform: uppercase; 99 | letter-spacing: 0.08ch; 100 | font-size: var(--text-xs); 101 | font-weight: 600; 102 | justify-content: space-between; 103 | align-items: center; 104 | } 105 | 106 | .modal-contents { 107 | padding: var(--space-6); 108 | width: 100%; 109 | height: 100%; 110 | overflow: hidden; 111 | } 112 | 113 | .modal-container[data-padding='none'] .modal-contents { 114 | padding: 0; 115 | } 116 | 117 | .modal-container[data-size='full-height'] { 118 | height: 85%; 119 | max-width: 85%; 120 | max-height: none; 121 | } 122 | 123 | .modal-container[data-size='full'] { 124 | width: 85%; 125 | height: 85%; 126 | max-width: none; 127 | max-height: none; 128 | } 129 | -------------------------------------------------------------------------------- /desktop/src/modals/modal.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import { ModalDefinition, ModalService } from './modal-service'; 3 | import { dependencies } from '../common/dependencies'; 4 | import { ShortcutService } from '../shortcuts/shortcut-service'; 5 | import './modal.css'; 6 | import { ModalElement } from './modal-element'; 7 | 8 | export class Modal { 9 | id: string; 10 | title?: string; 11 | 12 | #contents: ModalElement; 13 | #dialog: HTMLDialogElement; 14 | #elementName?: string; 15 | #shortcutService = dependencies.resolve('ShortcutService'); 16 | #modalService = dependencies.resolve('ModalService'); 17 | #closeCallback = () => this.#modalService.close(this.id); 18 | 19 | constructor(key: string, definition: ModalDefinition) { 20 | this.id = key; 21 | this.title = definition.title; 22 | this.#elementName = definition.element; 23 | } 24 | 25 | configureDialog(stackPosition: number) { 26 | this.#dialog = document.createElement('dialog'); 27 | this.#dialog.className = 'modal-container'; 28 | this.#dialog.setAttribute('data-size', this.#contents.options.size); 29 | this.#dialog.setAttribute('data-padding', this.#contents.options.padding); 30 | this.#dialog.setAttribute('data-position', this.#contents.options.position); 31 | this.#dialog.setAttribute('tabindex', '-1'); 32 | this.#dialog.setAttribute('role', 'dialog'); 33 | this.#dialog.setAttribute('aria-modal', 'true'); 34 | this.#dialog.setAttribute('aria-labelledby', 'modal-title'); 35 | this.#dialog.style.zIndex = String(300 + stackPosition); 36 | } 37 | 38 | open(stackPosition: number, options?: Record) { 39 | if (!this.#elementName) return null; 40 | this.#contents = document.createElement(this.#elementName) as ModalElement; 41 | this.configureDialog(stackPosition); 42 | 43 | render(this.template, this.#dialog); 44 | document.body.appendChild(this.#dialog); 45 | 46 | this.#contents.dispatchEvent( 47 | new CustomEvent('modal-open', { 48 | detail: { options }, 49 | }) 50 | ); 51 | 52 | if (this.#contents.options.blocking) { 53 | this.#dialog.showModal(); 54 | } else { 55 | this.#dialog.show(); 56 | } 57 | 58 | this.#dialog.focus(); 59 | this.#dialog.addEventListener('close', this.#closeCallback); 60 | this.#dialog.addEventListener('click', this.closeOnBackdropClick); 61 | 62 | this.#dialog.addEventListener('cancel', (event) => { 63 | event.preventDefault(); 64 | this.#closeCallback(); 65 | }); 66 | 67 | this.#shortcutService.register({ 68 | keys: 'Escape', 69 | callback: this.#closeCallback, 70 | }); 71 | } 72 | 73 | closeOnBackdropClick = (event: MouseEvent) => { 74 | if (event.target === this.#dialog && this.#contents.options.blocking) { 75 | this.#closeCallback(); 76 | } 77 | }; 78 | 79 | close() { 80 | this.#dialog.removeEventListener('click', this.closeOnBackdropClick); 81 | this.#dialog.removeEventListener('close', this.#closeCallback); 82 | this.#dialog.close(); 83 | this.#dialog.remove(); 84 | this.#shortcutService.deregister({ 85 | keys: 'Escape', 86 | callback: this.#closeCallback, 87 | }); 88 | } 89 | 90 | get template() { 91 | return html` 92 | ${this.#contents.getHeaderTemplate(this.title)} 93 | 94 | `; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /desktop/src/models/config-model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AIModelDescriptor, 3 | AIModelProviderConfig, 4 | AIModelProviderName, 5 | } from '../ai/ai-models'; 6 | import { Notifier } from '../common/notifier'; 7 | import { KeyStoreService } from '../storage/keystore-service'; 8 | 9 | export interface ConfigData { 10 | ai: { 11 | providers: { 12 | [id: string]: AIModelProviderConfig; 13 | }; 14 | primaryModel: AIModelDescriptor | null; 15 | globalHint: string; 16 | }; 17 | activeWorkspaceId: string | null; 18 | } 19 | 20 | export const initConfig: ConfigData = { 21 | ai: { 22 | providers: {}, 23 | primaryModel: null, 24 | globalHint: '', 25 | }, 26 | activeWorkspaceId: null, 27 | }; 28 | 29 | export interface ConfigNotification { 30 | type: 'model' | 'hint'; 31 | } 32 | 33 | export class ConfigModel { 34 | private store: KeyStoreService; 35 | private notifier = new Notifier(); 36 | readonly subscribe = this.notifier.subscribe; 37 | private config: ConfigData; 38 | 39 | constructor(store: KeyStoreService) { 40 | this.store = store; 41 | } 42 | 43 | async load() { 44 | this.config = this.store.get(); 45 | this.notifier.notify(); 46 | } 47 | 48 | updateModelProvider( 49 | provider: AIModelProviderName, 50 | providerConfig: AIModelProviderConfig 51 | ) { 52 | this.config.ai.providers[provider] = providerConfig; 53 | this.store.set(this.config); 54 | this.notifier.notify(); 55 | } 56 | 57 | updateActiveWorkspaceId(id: string | null) { 58 | this.config.activeWorkspaceId = id; 59 | this.store.set(this.config); 60 | } 61 | 62 | updatePrimaryModel(model: AIModelDescriptor) { 63 | this.config.ai.primaryModel = model; 64 | this.store.set(this.config); 65 | this.notifier.notify({ type: 'model' }); 66 | } 67 | 68 | updateGlobalHint(hint: string) { 69 | this.config.ai.globalHint = hint; 70 | this.store.set(this.config); 71 | this.notifier.notify({ type: 'hint' }); 72 | } 73 | 74 | get(): ConfigData; 75 | get(key: K): ConfigData[K]; 76 | get(key?: K) { 77 | if (key !== undefined) { 78 | return this.config[key]; 79 | } 80 | return this.config; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /desktop/src/models/message-model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionMessage as KernelActionMessage, 3 | InputMessage as KernelInputMessage, 4 | ResponseMessage as KernelResponseMessage, 5 | } from '@unternet/kernel'; 6 | 7 | type ExtendedMessageProperties = { workspaceId: string }; 8 | 9 | export type ActionMessage = KernelActionMessage & ExtendedMessageProperties; 10 | export type InputMessage = KernelInputMessage & ExtendedMessageProperties; 11 | export type ResponseMessage = KernelResponseMessage & ExtendedMessageProperties; 12 | 13 | export type ActionMessageRecord = Omit & { 14 | pid?: string; 15 | }; 16 | export type MessageRecord = 17 | | InputMessage 18 | | ResponseMessage 19 | | ActionMessageRecord; 20 | 21 | export type Message = ActionMessage | InputMessage | ResponseMessage; 22 | -------------------------------------------------------------------------------- /desktop/src/models/process-model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Process, 3 | ProcessContainer, 4 | ProcessRuntime, 5 | type ProcessSnapshot, 6 | } from '@unternet/kernel'; 7 | import { Notifier } from '../common/notifier'; 8 | import { DatabaseService } from '../storage/database-service'; 9 | import { Workspace } from './workspace-model'; 10 | 11 | export interface ProcessRecord extends ProcessSnapshot { 12 | workspaceId: string; 13 | } 14 | 15 | export class ProcessModel { 16 | private processDatabase: DatabaseService; 17 | private runtime: ProcessRuntime; 18 | private notifier = new Notifier(); 19 | readonly subscribe = this.notifier.subscribe; 20 | 21 | get processes() { 22 | return this.runtime.processes; 23 | } 24 | 25 | constructor( 26 | processDatabase: DatabaseService, 27 | runtime: ProcessRuntime 28 | ) { 29 | this.processDatabase = processDatabase; 30 | this.runtime = runtime; 31 | } 32 | 33 | async load() { 34 | const snapshots = await this.processDatabase.all(); 35 | for (const p of snapshots) this.runtime.hydrate(p); 36 | } 37 | 38 | get(pid: ProcessContainer['pid']) { 39 | return this.runtime.find(pid); 40 | } 41 | 42 | delete(pid: ProcessContainer['pid']) { 43 | this.processDatabase.delete(pid); 44 | } 45 | 46 | async deleteWhere(opts: { workspaceId: Workspace['id'] }) { 47 | this.processDatabase.deleteWhere({ workspaceId: opts.workspaceId }); 48 | } 49 | 50 | create(process: Process, workspaceId: Workspace['id']) { 51 | const container = this.runtime.spawn(process); 52 | const snapshot = container.serialize(); 53 | this.processDatabase.create({ 54 | workspaceId, 55 | ...snapshot, 56 | }); 57 | return container; 58 | } 59 | } 60 | 61 | export { ProcessSnapshot }; 62 | -------------------------------------------------------------------------------- /desktop/src/models/resource-model.ts: -------------------------------------------------------------------------------- 1 | import { Resource, resource } from '@unternet/kernel'; 2 | import webResource from '../protocols/buitin/resources'; 3 | import { Notifier } from '../common/notifier'; 4 | import { uriWithScheme } from '../common/utils/http'; 5 | import { DatabaseService } from '../storage/database-service'; 6 | import { WebProtocol } from '../protocols/http/protocol'; 7 | import { LocalAppletProtocol } from '../protocols/applet/local/protocol'; 8 | 9 | const initialResources: Array = new Array(); 10 | 11 | if (import.meta.env.APP_UNTERNET_API_KEY) { 12 | initialResources.push(webResource); 13 | } 14 | 15 | // Add local applets 16 | const localUris = await system.listLocalApplets(); 17 | const localApplets = await Promise.all( 18 | localUris.map((uri) => { 19 | return LocalAppletProtocol.createResource(uri); 20 | }) 21 | ); 22 | 23 | initialResources.push(...localApplets); 24 | 25 | interface ResourceModelInit { 26 | initialResources: Array; 27 | resourceDatabaseService: DatabaseService; 28 | } 29 | 30 | class ResourceModel { 31 | private resources = new Map(); 32 | private db: DatabaseService; 33 | private notifier = new Notifier(); 34 | public readonly subscribe = this.notifier.subscribe; 35 | 36 | constructor({ 37 | initialResources, 38 | resourceDatabaseService, 39 | }: ResourceModelInit) { 40 | this.db = resourceDatabaseService; 41 | initialResources.map(this.add.bind(this)); 42 | this.load(); 43 | } 44 | 45 | async load() { 46 | const allResources = await this.db.all(); 47 | 48 | for (const resource of allResources) { 49 | this.resources.set(resource.uri, resource); 50 | } 51 | this.notifier.notify(); 52 | } 53 | 54 | all() { 55 | return Array.from(this.resources.values()); 56 | } 57 | 58 | async register(uri: string) { 59 | uri = uriWithScheme(uri); 60 | 61 | try { 62 | const urlObj = new URL(uri); 63 | if (!['http', 'https'].includes(urlObj.protocol.replace(':', ''))) { 64 | throw new Error( 65 | `Adding resources from non-web sources not currently supported.` 66 | ); 67 | } 68 | } catch (e) { 69 | console.error(`Error registering resource '${uri}': ${e.message}`); 70 | } 71 | 72 | const newResource = await WebProtocol.createResource(uri); 73 | this.add(newResource); 74 | } 75 | 76 | add(resource: Resource) { 77 | this.resources.set(resource.uri, resource); 78 | if (!resource.uri.startsWith('applet+local:')) this.db.put(resource); 79 | this.notifier.notify(); 80 | } 81 | 82 | get(uri: string) { 83 | const result = this.resources.get(uri); 84 | if (!result) { 85 | throw new Error(`No resource matches this URI: ${JSON.stringify(uri)}`); 86 | } 87 | 88 | return result; 89 | } 90 | 91 | async remove(uri: string) { 92 | this.resources.delete(uri); 93 | await this.db.delete(uri); 94 | this.notifier.notify(); 95 | } 96 | } 97 | 98 | export { ResourceModel, initialResources }; 99 | -------------------------------------------------------------------------------- /desktop/src/protocols/applet/local/protocol.ts: -------------------------------------------------------------------------------- 1 | import { ActionProposal, Protocol, resource, Resource } from '@unternet/kernel'; 2 | import { WebProcess } from '../../http/processes'; 3 | import { getMetadata } from '../../../common/utils/http'; 4 | 5 | export class LocalAppletProtocol extends Protocol { 6 | scheme = ['applet+local']; 7 | searchEnabledResources: Array = []; 8 | 9 | // TODO: Make this a standard part of the kernel 10 | static async createResource(url: string): Promise { 11 | const metadata = await getMetadata(url); 12 | 13 | const webResource = resource({ 14 | uri: url, 15 | ...metadata, 16 | }); 17 | 18 | return webResource; 19 | } 20 | 21 | async handleAction(action: ActionProposal) { 22 | const process = await WebProcess.create(action.uri); 23 | await process.handleAction(action); 24 | console.log('📣 Handling action', action, process); 25 | if (action.display === 'snippet') return process.data; 26 | console.log('📣 Returning process'); 27 | return process; 28 | } 29 | } 30 | 31 | const localAppletProtocol = new LocalAppletProtocol(); 32 | localAppletProtocol.registerProcess(WebProcess); 33 | 34 | export { localAppletProtocol }; 35 | -------------------------------------------------------------------------------- /desktop/src/protocols/buitin/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/desktop/src/protocols/buitin/icon-128x128.png -------------------------------------------------------------------------------- /desktop/src/protocols/buitin/protocol.ts: -------------------------------------------------------------------------------- 1 | import { ActionProposal, Protocol } from '@unternet/kernel'; 2 | import { WebProcess } from '../http/processes'; 3 | import { unternet } from '../../common/unternet'; 4 | 5 | export class BuiltinProtocol extends Protocol { 6 | scheme = 'builtin'; 7 | 8 | async handleAction(directive: ActionProposal) { 9 | switch (directive.actionId) { 10 | case 'search': 11 | const results = await unternet.lookup.query({ 12 | q: directive.args.q, 13 | }); 14 | return results; 15 | case 'open': 16 | return new WebProcess({ url: directive.args.url }); 17 | default: 18 | throw new Error( 19 | `Invalid actionID for directive. URI: ${directive.uri}, ID: ${directive.actionId}.` 20 | ); 21 | } 22 | } 23 | } 24 | 25 | export const builtinProtocol = new BuiltinProtocol(); 26 | -------------------------------------------------------------------------------- /desktop/src/protocols/buitin/resources.ts: -------------------------------------------------------------------------------- 1 | import { resource } from '@unternet/kernel'; 2 | import iconSrc from './icon-128x128.png'; 3 | 4 | const search = resource({ 5 | uri: 'builtin:search', 6 | name: 'Search', 7 | description: 'Search the web', 8 | icons: [ 9 | { 10 | src: iconSrc, 11 | }, 12 | ], 13 | actions: { 14 | search: { 15 | description: 16 | 'Search the entire web for information, from a set of keywords.', 17 | params_schema: { 18 | type: 'object', 19 | properties: { 20 | q: { 21 | type: 'string', 22 | description: 'A search query', 23 | }, 24 | }, 25 | required: ['q'], 26 | }, 27 | }, 28 | open: { 29 | description: 'Show a particular web page, inline in the conversation.', 30 | params_schema: { 31 | type: 'object', 32 | properties: { 33 | url: { 34 | type: 'string', 35 | description: 'The URL of the site to open.', 36 | }, 37 | }, 38 | required: ['url'], 39 | }, 40 | }, 41 | }, 42 | }); 43 | 44 | export default search; 45 | -------------------------------------------------------------------------------- /desktop/src/protocols/http/processes.ts: -------------------------------------------------------------------------------- 1 | import { ActionProposal, Process, ResourceIcon } from '@unternet/kernel'; 2 | import { Applet, applets } from '@web-applets/sdk'; 3 | import { getMetadata } from '../../common/utils/http'; 4 | 5 | interface WebProcessState { 6 | url: string; 7 | title?: string; 8 | icons?: ResourceIcon[]; 9 | data?: any; 10 | } 11 | 12 | // TODO: Put this somewhere better? 13 | const hiddenContainer = document.createElement('div'); 14 | hiddenContainer.style.display = 'none'; 15 | document.body.appendChild(hiddenContainer); 16 | 17 | export class WebProcess extends Process { 18 | url: string; 19 | webview: HTMLIFrameElement; // TODO: WebviewTag 20 | title?: string; 21 | description?: string; 22 | icons?: ResourceIcon[]; 23 | data?: any; 24 | 25 | static async create(url: string) { 26 | const process = new WebProcess({ url }); 27 | const metadata = await getMetadata(url); 28 | process.title = metadata.title; 29 | process.description = metadata.description; 30 | process.icons = metadata.icons; 31 | return process; 32 | } 33 | 34 | static resume(state: WebProcessState) { 35 | const process = new WebProcess(state); 36 | process.icons = state.icons; 37 | process.title = state.title; 38 | process.data = state.data; 39 | return process; 40 | } 41 | 42 | constructor(state: WebProcessState) { 43 | super(); 44 | this.url = state.url; 45 | this.webview = document.createElement('iframe'); 46 | this.webview.src = state.url; 47 | this.webview.style.border = 'none'; 48 | this.webview.style.width = '100%'; 49 | this.webview.style.height = '100%'; 50 | this.webview.style.minHeight = '350px'; 51 | this.webview.style.background = 'var(--color-bg-content)'; 52 | } 53 | 54 | async handleAction(action: ActionProposal) { 55 | const applet = await this.connectApplet(hiddenContainer); 56 | await applet.sendAction(action.actionId, action.args); 57 | this.data = applet.data; 58 | } 59 | 60 | describe() { 61 | return { 62 | url: this.url, 63 | title: this.title, 64 | description: this.description, 65 | data: this.data, 66 | }; 67 | } 68 | 69 | mount(host: HTMLElement): void | Promise { 70 | host.appendChild(this.webview); 71 | setTimeout(async () => { 72 | const applet = await applets.connect(this.webview.contentWindow); 73 | applet.data = this.data; 74 | }, 0); 75 | } 76 | 77 | unmount(): void { 78 | this.disconnectApplet(); 79 | } 80 | 81 | async connectApplet(element: HTMLElement): Promise { 82 | element.appendChild(this.webview); 83 | const applet = await applets.connect(this.webview.contentWindow); 84 | applet.data = this.data; 85 | return applet; 86 | } 87 | 88 | disconnectApplet(element?: HTMLElement) { 89 | this.webview.remove(); 90 | } 91 | 92 | get snapshot(): WebProcessState { 93 | return { 94 | url: this.url, 95 | title: this.title, 96 | icons: this.icons, 97 | data: this.data, 98 | }; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /desktop/src/protocols/http/protocol.ts: -------------------------------------------------------------------------------- 1 | import { ActionProposal, Protocol, resource, Resource } from '@unternet/kernel'; 2 | import { WebProcess } from './processes'; 3 | import { getMetadata } from '../../common/utils/http'; 4 | import { unternet } from '../../common/unternet'; 5 | 6 | export class WebProtocol extends Protocol { 7 | scheme = ['http', 'https']; 8 | searchEnabledResources: Array = []; 9 | 10 | // TODO: Make this a standard part of the kernel 11 | static async createResource(url: string): Promise { 12 | const metadata = await getMetadata(url); 13 | 14 | if (!metadata.actions) { 15 | metadata.actions = { 16 | __search__: { 17 | description: `Search within this website.`, 18 | display: 'snippet', 19 | params_schema: { 20 | type: 'object', 21 | properties: { 22 | q: { 23 | type: 'string', 24 | description: 'The search query.', 25 | }, 26 | }, 27 | required: ['q'], 28 | }, 29 | }, 30 | }; 31 | } 32 | 33 | const webResource = resource({ 34 | uri: url, 35 | ...metadata, 36 | }); 37 | 38 | return webResource; 39 | } 40 | 41 | async handleAction(action: ActionProposal) { 42 | if (action.actionId === '__search__') { 43 | return await unternet.lookup.query({ 44 | q: action.args.q, 45 | webpages: { sites: [action.uri] }, 46 | }); 47 | } 48 | const process = await WebProcess.create(action.uri); 49 | await process.handleAction(action); 50 | console.log('Handling action', action, process); 51 | if (action.display === 'snippet') return process.data; 52 | console.log('Returning process'); 53 | return process; 54 | } 55 | } 56 | 57 | const webProtocol = new WebProtocol(); 58 | webProtocol.registerProcess(WebProcess); 59 | 60 | export { webProtocol }; 61 | -------------------------------------------------------------------------------- /desktop/src/protocols/index.ts: -------------------------------------------------------------------------------- 1 | import { localAppletProtocol } from './applet/local/protocol'; 2 | import { builtinProtocol } from './buitin/protocol'; 3 | import { webProtocol } from './http/protocol'; 4 | 5 | export const protocols = [localAppletProtocol, builtinProtocol, webProtocol]; 6 | -------------------------------------------------------------------------------- /desktop/src/shortcuts/global-shortcuts.ts: -------------------------------------------------------------------------------- 1 | import { dependencies } from '../common/dependencies'; 2 | import { TabModel } from '../deprecated/tabs'; 3 | import { ModalService } from '../modals/modal-service'; 4 | import { ShortcutService } from './shortcut-service'; 5 | 6 | import { WorkspaceModel } from '../models/workspace-model'; 7 | 8 | export function registerGlobalShortcuts() { 9 | const shortcutService = 10 | dependencies.resolve('ShortcutService'); 11 | // const tabModel = dependencies.resolve('TabModel'); 12 | const modalService = dependencies.resolve('ModalService'); 13 | const workspaceModel = dependencies.resolve('WorkspaceModel'); 14 | 15 | shortcutService.register({ 16 | keys: 'Meta+N', 17 | callback: () => { 18 | modalService.open('new-workspace'); 19 | }, 20 | description: 'Create new workspace', 21 | }); 22 | 23 | // shortcutService.register('Meta+Shift+]', () => { 24 | // tabModel.activateNext(); 25 | // }); 26 | 27 | // shortcutService.register('Meta+Shift+[', () => { 28 | // tabModel.activatePrev(); 29 | // }); 30 | 31 | // Ctrl+, or Meta+, to open settings 32 | shortcutService.register({ 33 | keys: 'Meta+,', 34 | callback: () => { 35 | modalService.open('settings'); 36 | }, 37 | description: 'Open settings', 38 | }); 39 | 40 | shortcutService.register({ 41 | keys: 'Ctrl+,', 42 | callback: () => { 43 | modalService.open('settings'); 44 | }, 45 | description: 'Open settings', 46 | }); 47 | 48 | // Meta+K: Archive messages up to the most recent message in the active workspace 49 | shortcutService.register({ 50 | keys: 'Meta+K', 51 | callback: () => { 52 | workspaceModel.archiveMessages(); 53 | }, 54 | description: 'Archive messages in active workspace', 55 | }); 56 | 57 | shortcutService.register({ 58 | keys: 'Meta+Shift+L', 59 | callback: () => { 60 | workspaceModel.archiveMessages(); 61 | }, 62 | description: 'Archive messages in active workspace', 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /desktop/src/shortcuts/shortcut-service.ts: -------------------------------------------------------------------------------- 1 | type Shortcut = { 2 | keys: string; // e.g., "Meta+Shift+T" 3 | callback: (e: KeyboardEvent) => void; 4 | description?: string; // Optional, for UI display in @shortcuts-section 5 | }; 6 | 7 | export class ShortcutService { 8 | /** 9 | * Returns a list of all registered shortcuts (top-most for each key combination). 10 | */ 11 | public listShortcuts(): Shortcut[] { 12 | const result: Shortcut[] = []; 13 | for (const stack of this.shortcuts.values()) { 14 | if (stack.length > 0) { 15 | result.push(stack[stack.length - 1]); // top-most shortcut for this key 16 | } 17 | } 18 | return result; 19 | } 20 | private shortcuts: Map = new Map(); 21 | 22 | constructor() { 23 | document.addEventListener('keydown', (e: KeyboardEvent) => 24 | this.handleKeydown(e) 25 | ); 26 | } 27 | 28 | register(shortcut: Shortcut): void { 29 | const normalizedKeys = this.normalizeKeys(shortcut.keys); 30 | const shortcutWithNormalizedKeys = { ...shortcut, keys: normalizedKeys }; 31 | 32 | if (!this.shortcuts.has(normalizedKeys)) { 33 | this.shortcuts.set(normalizedKeys, []); 34 | } else { 35 | console.warn(`Duplicate shortcut added for '${normalizedKeys}'.`); 36 | } 37 | 38 | const shortcutStack = this.shortcuts.get(normalizedKeys)!; 39 | shortcutStack.push(shortcutWithNormalizedKeys); 40 | } 41 | 42 | deregister(shortcut: Shortcut): void { 43 | const normalizedKeys = this.normalizeKeys(shortcut.keys); 44 | 45 | if (!this.shortcuts.has(normalizedKeys)) { 46 | console.warn(`Shortcut for "${normalizedKeys}" is not registered.`); 47 | return; 48 | } 49 | 50 | const shortcutStack = this.shortcuts.get(normalizedKeys)!; 51 | const index = shortcutStack.findIndex( 52 | (s) => s.callback === shortcut.callback 53 | ); 54 | 55 | if (index === -1) { 56 | console.warn(`Callback for shortcut "${normalizedKeys}" not found.`); 57 | return; 58 | } 59 | 60 | shortcutStack.splice(index, 1); 61 | 62 | if (shortcutStack.length === 0) { 63 | this.shortcuts.delete(normalizedKeys); 64 | } 65 | } 66 | 67 | private handleKeydown(e: KeyboardEvent): void { 68 | const keyCombination = this.getKeyCombination(e); 69 | const shortcutStack = this.shortcuts.get(keyCombination); 70 | 71 | if (shortcutStack && shortcutStack.length > 0) { 72 | e.preventDefault(); 73 | const activeShortcut = shortcutStack[shortcutStack.length - 1]; 74 | activeShortcut.callback(e); 75 | } 76 | } 77 | 78 | private getKeyCombination(e: KeyboardEvent): string { 79 | const keys: string[] = []; 80 | if (e.ctrlKey) keys.push('Ctrl'); 81 | if (e.metaKey) keys.push('Meta'); 82 | if (e.altKey) keys.push('Alt'); 83 | if (e.shiftKey) keys.push('Shift'); 84 | keys.push(e.key); 85 | return this.normalizeKeys(keys.join('+')); 86 | } 87 | 88 | private normalizeKeys(keys: string): string { 89 | if (typeof keys !== 'string' || !keys) { 90 | throw new Error( 91 | `ShortcutService.normalizeKeys: 'keys' must be a non-empty string, got: ${JSON.stringify(keys)}` 92 | ); 93 | } 94 | return keys 95 | .split('+') 96 | .map((key) => key.trim().toUpperCase()) 97 | .sort() 98 | .join('+'); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /desktop/src/storage/database-service.ts: -------------------------------------------------------------------------------- 1 | import { IndexableType, Table } from 'dexie'; 2 | import { db } from './indexed-db'; 3 | 4 | export type WhereConditions = { [key: string]: IndexableType }; 5 | 6 | export class DatabaseService { 7 | table: Table; 8 | 9 | constructor(tableName: string) { 10 | this.table = db[tableName]; 11 | } 12 | 13 | async create(item: T): Promise { 14 | return this.table.add(item); 15 | } 16 | 17 | async get(id: Id): Promise { 18 | return this.table.get(id); 19 | } 20 | 21 | async put(item: T): Promise { 22 | return this.table.put(item); 23 | } 24 | 25 | async delete(id: Id): Promise { 26 | return this.table.delete(id); 27 | } 28 | 29 | // TODO: Expand to allow multiple conditions 30 | async where(conditions: WhereConditions) { 31 | const condition = Object.keys(conditions)[0]; 32 | return this.table.where(condition).equals(conditions[condition]).toArray(); 33 | } 34 | 35 | async deleteWhere(conditions: WhereConditions) { 36 | return await Promise.all( 37 | Object.keys(conditions).map((condition) => { 38 | return this.table 39 | .where(condition) 40 | .equals(conditions[condition]) 41 | .delete(); 42 | }) 43 | ); 44 | } 45 | 46 | async update(id: Id, item: Partial): Promise { 47 | await this.table.update(id, item); 48 | } 49 | 50 | all(): Promise { 51 | return this.table.toArray(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /desktop/src/storage/indexed-db.ts: -------------------------------------------------------------------------------- 1 | import { Dexie, Table } from 'dexie'; 2 | import { WorkspaceRecord } from '../models/workspace-model'; 3 | import { MessageRecord } from '../models/message-model'; 4 | import { ProcessSnapshot } from '../models/process-model'; 5 | import { Resource } from '@unternet/kernel'; 6 | 7 | export class IndexedDB extends Dexie { 8 | workspaces!: Table; 9 | messages!: Table; 10 | processes!: Table; 11 | resources!: Table; 12 | 13 | constructor() { 14 | super('DB'); 15 | 16 | this.version(1).stores({ 17 | workspaces: 'id', 18 | messages: 'id,workspaceId,active', 19 | processes: 'pid,workspaceId', 20 | resources: 'uri', 21 | }); 22 | } 23 | } 24 | 25 | export const db = new IndexedDB(); 26 | -------------------------------------------------------------------------------- /desktop/src/storage/keystore-service.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | 3 | export class KeyStoreService { 4 | private _name: string; 5 | private _value: ValueType; 6 | 7 | get value() { 8 | return this._value; 9 | } 10 | 11 | constructor(name: string, initValue: any) { 12 | this._name = name; 13 | 14 | const currentValue = localStorage.getItem(name); 15 | if (currentValue) { 16 | this._value = JSON.parse(currentValue) as ValueType; 17 | } else { 18 | this._value = initValue; 19 | localStorage.setItem(name, JSON.stringify(initValue)); 20 | } 21 | } 22 | 23 | get() { 24 | const value = localStorage.getItem(this._name)!; 25 | return JSON.parse(value) as ValueType; 26 | } 27 | 28 | update(updater: Partial | ((value: ValueType) => void)) { 29 | const currentValue = this.get(); 30 | if (typeof updater === 'function') { 31 | this.set(produce(currentValue, updater)); 32 | } else { 33 | this.set({ ...currentValue, ...updater }); 34 | } 35 | } 36 | 37 | set(value: ValueType) { 38 | localStorage.setItem(this._name, JSON.stringify(value)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /desktop/src/ui/app-root.css: -------------------------------------------------------------------------------- 1 | /* Main layout */ 2 | 3 | app-root { 4 | width: 100%; 5 | height: 100%; 6 | overflow: hidden; 7 | } 8 | 9 | app-root .stack { 10 | width: 100%; 11 | height: 100%; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | app-root .workspace-content { 17 | position: relative; 18 | flex-grow: 1; 19 | background: var(--color-bg-page); 20 | display: flex; 21 | justify-content: center; 22 | overflow: hidden; 23 | } 24 | 25 | app-root .toolbar { 26 | position: relative; 27 | background: var(--color-bg-chrome); 28 | border-top: 1px solid var(--color-border); 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | color: var(--color-text-muted); 33 | } 34 | 35 | .toolbar resource-bar { 36 | align-self: stretch; 37 | } 38 | -------------------------------------------------------------------------------- /desktop/src/ui/app-root.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import './top-bar/top-bar'; 3 | import './toolbar/command-bar'; 4 | import './app-root.css'; 5 | import './thread/thread-view'; 6 | import { dependencies } from '../common/dependencies'; 7 | import { WorkspaceModel } from '../models/workspace-model'; 8 | 9 | export class AppRoot extends HTMLElement { 10 | workspaceModel = dependencies.resolve('WorkspaceModel'); 11 | 12 | connectedCallback() { 13 | this.workspaceModel.subscribe(this.update.bind(this)); 14 | this.update(); 15 | } 16 | 17 | update() { 18 | const ws = this.workspaceModel.activeWorkspace; 19 | if (!ws) return; 20 | 21 | const template = html` 22 | 23 |
24 |
25 | 26 |
27 |
28 | 29 | 30 |
31 |
32 | `; 33 | 34 | render(template, this); 35 | } 36 | } 37 | 38 | customElements.define('app-root', AppRoot); 39 | -------------------------------------------------------------------------------- /desktop/src/ui/common/button.css: -------------------------------------------------------------------------------- 1 | un-button { 2 | pointer-events: none; 3 | } 4 | 5 | un-button button { 6 | pointer-events: auto; 7 | transition: all 100ms; 8 | overflow: hidden; 9 | border: none; 10 | --button-height: 24px; 11 | --button-color: var(--color-action-800); 12 | --button-text-color: var(--color-action-0); 13 | } 14 | 15 | un-button button { 16 | display: inline-flex; 17 | border-radius: var(--rounded); 18 | align-items: center; 19 | justify-content: center; 20 | height: var(--button-height); 21 | line-height: var(--button-height); 22 | gap: var(--space-2); 23 | 24 | background-color: var(--button-color); 25 | color: var(--button-text-color); 26 | box-shadow: var(--button-shadows); 27 | 28 | &:hover, 29 | &:focus { 30 | background-color: color-mix( 31 | in oklch, 32 | var(--button-color) 100%, 33 | var(--color-grey-0) 25% 34 | ); 35 | } 36 | 37 | &:focus { 38 | outline: var(--outline); 39 | outline-offset: var(--outline-offset); 40 | 41 | /* So that focus outline doesn't show on pointer events */ 42 | &:not(:focus-visible) { 43 | outline: none; 44 | } 45 | } 46 | 47 | &:active { 48 | box-shadow: none; 49 | background-color: var(--button-color); 50 | } 51 | 52 | &:disabled, 53 | &.loading { 54 | cursor: not-allowed; 55 | pointer-events: none; 56 | opacity: 0.5; 57 | box-shadow: none; 58 | } 59 | 60 | &.secondary { 61 | --button-color: var(--color-neutral-200); 62 | --button-text-color: var(--color-neutral-1000); 63 | &:hover { 64 | --button-text-color: var(--color-action-800); 65 | } 66 | } 67 | 68 | &.negative { 69 | --button-color: var(--color-error-800); 70 | --button-text-color: var(--color-error-0); 71 | } 72 | 73 | &.outline, 74 | &.ghost, 75 | &.link { 76 | --button-color: transparent; 77 | --button-text-color: currentColor; 78 | box-shadow: none; 79 | 80 | &:hover, 81 | &:focus { 82 | --button-color: var(--color-neutral-200); 83 | opacity: 1; 84 | } 85 | } 86 | 87 | &.link { 88 | --button-text-color: var(--color-action-800); 89 | &:hover { 90 | --button-text-color: var(--color-action-800); 91 | } 92 | } 93 | 94 | &.outline { 95 | --button-text-color: currentColor; 96 | border: 1px solid color-mix(in oklch, currentColor 85%, transparent 100%); 97 | &:hover { 98 | border-color: currentColor; 99 | } 100 | } 101 | 102 | &.link { 103 | --button-height: 18px; 104 | font-size: var(--text-sm); 105 | } 106 | 107 | &.small { 108 | --button-height: 18px; 109 | font-size: var(--text-sm); 110 | } 111 | 112 | &.large { 113 | --button-height: 28px; 114 | } 115 | 116 | .icon-container { 117 | display: flex; 118 | align-items: center; 119 | opacity: 0.75; 120 | } 121 | 122 | &:hover .icon-container { 123 | opacity: 1; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /desktop/src/ui/common/checkbox.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'lit'; 2 | import './icons/icon'; 3 | 4 | export class CheckboxElement extends HTMLElement { 5 | private input: HTMLInputElement; 6 | private label: HTMLLabelElement; 7 | 8 | static get styles() { 9 | return css` 10 | .checkbox-wrapper { 11 | display: flex; 12 | align-items: center; 13 | gap: var(--space-3); 14 | } 15 | 16 | .checkbox-wrapper:has(input:disabled) { 17 | cursor: not-allowed; 18 | opacity: 0.6; 19 | } 20 | 21 | input[type='checkbox'] { 22 | appearance: none; 23 | margin: 0; 24 | width: 18px; 25 | height: 18px; 26 | border: 1px solid var(--color-border-strong); 27 | border-radius: var(--rounded-sm); 28 | background-color: var(--color-bg-input); 29 | display: grid; 30 | place-content: center; 31 | } 32 | 33 | input[type='checkbox']:checked { 34 | background-color: var(--color-action-800); 35 | border-color: var(--color-action-800); 36 | } 37 | 38 | input[type='checkbox']:checked::before { 39 | content: ''; 40 | width: 4px; 41 | height: 9px; 42 | border: solid white; 43 | border-width: 0 2px 2px 0; 44 | transform: rotate(45deg); 45 | margin-bottom: 2px; 46 | } 47 | 48 | input[type='checkbox']:disabled { 49 | cursor: not-allowed; 50 | } 51 | 52 | input[type='checkbox']:focus { 53 | outline: 2px solid var(--color-outline); 54 | } 55 | `; 56 | } 57 | 58 | constructor() { 59 | super(); 60 | this.attachShadow({ mode: 'open' }); 61 | 62 | this.label = document.createElement('label'); 63 | this.label.className = 'checkbox-wrapper'; 64 | 65 | this.input = document.createElement('input'); 66 | this.input.type = 'checkbox'; 67 | this.input.addEventListener('change', this.handleChange.bind(this)); 68 | 69 | this.label.appendChild(this.input); 70 | } 71 | 72 | static get observedAttributes() { 73 | return ['disabled', 'label', 'checked']; 74 | } 75 | 76 | connectedCallback() { 77 | const style = document.createElement('style'); 78 | style.textContent = CheckboxElement.styles.toString(); 79 | this.shadowRoot!.appendChild(style); 80 | 81 | const slot = document.createElement('slot'); 82 | slot.textContent = this.getAttribute('label') || ''; 83 | this.label.appendChild(slot); 84 | 85 | this.shadowRoot!.appendChild(this.label); 86 | this.updateCheckbox(); 87 | } 88 | 89 | attributeChangedCallback( 90 | name: string, 91 | _: string | null, 92 | newValue: string | null 93 | ) { 94 | if (!this.input) return; 95 | 96 | switch (name) { 97 | case 'disabled': 98 | this.input.disabled = newValue !== null; 99 | break; 100 | case 'checked': 101 | this.input.checked = newValue !== null; 102 | break; 103 | case 'label': 104 | const slot = this.shadowRoot?.querySelector('slot'); 105 | if (slot) slot.textContent = newValue || ''; 106 | break; 107 | } 108 | } 109 | 110 | get checked(): boolean { 111 | return this.hasAttribute('checked'); 112 | } 113 | 114 | set checked(value: boolean) { 115 | if (value) { 116 | this.setAttribute('checked', ''); 117 | } else { 118 | this.removeAttribute('checked'); 119 | } 120 | } 121 | 122 | private handleChange(e: Event) { 123 | const input = e.target as HTMLInputElement; 124 | this.checked = input.checked; 125 | } 126 | 127 | private updateCheckbox() { 128 | this.input.checked = this.checked; 129 | this.input.disabled = this.hasAttribute('disabled'); 130 | } 131 | } 132 | 133 | customElements.define('un-checkbox', CheckboxElement); 134 | -------------------------------------------------------------------------------- /desktop/src/ui/common/context-menu.ts: -------------------------------------------------------------------------------- 1 | import { NativeMenu, NativeMenuOption } from './menu/native-menu'; 2 | 3 | // Renderer-specific menu option type: click is zero-arg only 4 | export type RendererMenuOption = Omit & { 5 | click?: () => void; 6 | }; 7 | 8 | /** 9 | * Shows a native context menu at the specified (x, y) screen coordinates. 10 | * @param options Array of NativeMenuOption to build the menu 11 | * @param x X coordinate (screen, not page) 12 | * @param y Y coordinate (screen, not page) 13 | */ 14 | export function showContextMenu( 15 | options: RendererMenuOption[], 16 | x: number, 17 | y: number 18 | ): void { 19 | const showNativeMenu = (window as any).electronAPI?.showNativeMenu; 20 | if (!showNativeMenu) return; 21 | const menu = new NativeMenu(); 22 | 23 | // Build a map from value to click handler, and strip click from options 24 | const actionMap = new Map void>(); 25 | const serializableOptions = options.map((opt) => { 26 | const { click, ...rest } = opt; 27 | if (typeof opt.value === 'string' && typeof click === 'function') { 28 | actionMap.set(opt.value, click); 29 | } 30 | return rest; 31 | }); 32 | 33 | const menuOptions = menu.buildMenuOptions( 34 | serializableOptions as NativeMenuOption[] 35 | ); 36 | showNativeMenu(menuOptions, { x, y }).then( 37 | (selectedId: string | undefined) => { 38 | if (selectedId) { 39 | const value = menu.getValueForId(selectedId); 40 | if (value && actionMap.has(value)) { 41 | actionMap.get(value)!(); 42 | } 43 | } 44 | } 45 | ); 46 | } 47 | 48 | /** 49 | * Attaches a native context menu to the given element. 50 | * @param element The target HTMLElement 51 | * @param options A NativeMenuOption[] or a function returning one (for dynamic menus) 52 | */ 53 | export function attachContextMenu( 54 | element: HTMLElement, 55 | options: RendererMenuOption[] | (() => RendererMenuOption[]) 56 | ): void { 57 | element.addEventListener('contextmenu', (e: MouseEvent) => { 58 | e.preventDefault(); 59 | e.stopPropagation(); 60 | const menuOptions = typeof options === 'function' ? options() : options; 61 | // Use clientX/clientY for menu popup location 62 | showContextMenu(menuOptions, e.clientX, e.clientY); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /desktop/src/ui/common/icons/icon-registry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IconNode, 3 | HelpCircle, 4 | X, 5 | Home, 6 | Plus, 7 | Bug, 8 | Shapes, 9 | Settings, 10 | Pencil, 11 | Settings2, 12 | Check, 13 | ChevronsUpDown, 14 | CornerDownLeft, 15 | GripHorizontal, 16 | Trash, 17 | History, 18 | RefreshCw, 19 | AlertTriangle, 20 | Loader, 21 | Info, 22 | ExternalLink, 23 | Download, 24 | ArrowLeft, 25 | ArrowRight, 26 | ArrowUp, 27 | ArrowDown, 28 | Search, 29 | Upload, 30 | Paperclip, 31 | CheckCheck, 32 | } from 'lucide'; 33 | import { broom } from '@lucide/lab'; 34 | 35 | export const icons = { 36 | HelpCircle, 37 | X, 38 | Home, 39 | Plus, 40 | Bug, 41 | Shapes, 42 | Settings, 43 | Pencil, 44 | Settings2, 45 | Check, 46 | ChevronsUpDown, 47 | CornerDownLeft, 48 | GripHorizontal, 49 | Trash, 50 | History, 51 | RefreshCw, 52 | AlertTriangle, 53 | Loader, 54 | Info, 55 | ExternalLink, 56 | Download, 57 | ArrowLeft, 58 | ArrowRight, 59 | ArrowUp, 60 | ArrowDown, 61 | Search, 62 | Upload, 63 | Paperclip, 64 | CheckCheck, 65 | Broom: broom, 66 | } as const; 67 | 68 | export type CanonicalIconName = keyof typeof icons; 69 | export type IconName = 70 | | CanonicalIconName 71 | | keyof typeof ALIASES 72 | | Lowercase 73 | | string; 74 | 75 | export const ALIASES: Record = { 76 | close: 'X', 77 | toolbox: 'Shapes', 78 | sliders: 'Settings2', 79 | dropdown: 'ChevronsUpDown', 80 | enter: 'CornerDownLeft', 81 | handle: 'GripHorizontal', 82 | delete: 'Trash', 83 | refresh: 'RefreshCw', 84 | error: 'AlertTriangle', 85 | loading: 'Loader', 86 | external: 'ExternalLink', 87 | left: 'ArrowLeft', 88 | right: 'ArrowRight', 89 | up: 'ArrowUp', 90 | down: 'ArrowDown', 91 | attachment: 'Paperclip', 92 | archive: 'Broom', 93 | }; 94 | 95 | export type IconFactory = (attrs: Record) => IconNode; 96 | 97 | export function getIcon(name: IconName): IconFactory { 98 | const wrapIconNode = 99 | (node: IconNode): IconFactory => 100 | (_attrs: Record) => 101 | node; 102 | if (!name) return wrapIconNode(icons.HelpCircle); 103 | 104 | // Resolve alias 105 | let canonical = name as string; 106 | if (ALIASES[canonical as keyof typeof ALIASES]) { 107 | canonical = ALIASES[canonical as keyof typeof ALIASES]; 108 | } 109 | 110 | // Prepare candidate keys to try in order 111 | const pascal = canonical.replace( 112 | /(^|[-_])(\w)/g, 113 | (_: string, __: string, p2: string) => p2.toUpperCase() 114 | ); 115 | const capFirst = canonical.charAt(0).toUpperCase() + canonical.slice(1); 116 | const lower = canonical.toLowerCase(); 117 | const keysToTry = [canonical, pascal, capFirst]; 118 | 119 | // Try direct keys 120 | for (const key of keysToTry) { 121 | const candidate = (icons as any)[key]; 122 | if (typeof candidate === 'function') return candidate; 123 | if (candidate) return wrapIconNode(candidate); 124 | } 125 | 126 | // Try any key matching lowercased name 127 | for (const key of Object.keys(icons)) { 128 | if (key.toLowerCase() === lower) { 129 | const icon = (icons as any)[key]; 130 | if (typeof icon === 'function') return icon; 131 | if (icon) return wrapIconNode(icon); 132 | } 133 | } 134 | 135 | // Fallback 136 | return wrapIconNode(icons.HelpCircle); 137 | } 138 | -------------------------------------------------------------------------------- /desktop/src/ui/common/icons/icon.ts: -------------------------------------------------------------------------------- 1 | import { html, css, render } from 'lit'; 2 | import { createElement } from 'lucide'; 3 | import { getIcon } from './icon-registry'; 4 | import { attachStyles } from '../../../common/utils/dom'; 5 | 6 | export type IconSize = 'small' | 'medium' | 'large'; 7 | const sizeMap = { 8 | small: '12', 9 | medium: '14', 10 | large: '18', 11 | }; 12 | 13 | export class IconElement extends HTMLElement { 14 | static get observedAttributes() { 15 | return ['name', 'size', 'spin']; 16 | } 17 | 18 | #name: string | null = null; 19 | #size: IconSize = 'medium'; 20 | #spin: string | null = null; 21 | #shadow: ShadowRoot; 22 | 23 | constructor() { 24 | super(); 25 | this.#shadow = this.attachShadow({ mode: 'open' }); 26 | } 27 | 28 | attributeChangedCallback( 29 | attr: string, 30 | oldVal: string | null, 31 | newVal: string | null 32 | ) { 33 | if (oldVal === newVal) return; 34 | switch (attr) { 35 | case 'name': 36 | this.#name = newVal; 37 | break; 38 | case 'size': 39 | if (newVal === 'small' || newVal === 'medium' || newVal === 'large') { 40 | this.#size = newVal; 41 | } else { 42 | this.#size = 'medium'; 43 | } 44 | break; 45 | case 'spin': 46 | this.#spin = newVal; 47 | break; 48 | } 49 | render(this.#template, this.#shadow); 50 | } 51 | 52 | connectedCallback() { 53 | attachStyles(this.#shadow, IconElement.styles.toString()); 54 | render(this.#template, this.#shadow); 55 | } 56 | 57 | get #iconAttributes() { 58 | return { 59 | stroke: 'currentColor', 60 | 'stroke-linecap': 'round', 61 | 'stroke-linejoin': 'round', 62 | width: sizeMap[this.#size || 'medium'], 63 | height: sizeMap[this.#size || 'medium'], 64 | fill: 'none', 65 | }; 66 | } 67 | 68 | get #template() { 69 | const iconFactory = getIcon(this.#name || 'HelpCircle'); 70 | const attrs = this.#iconAttributes; 71 | const svgNode = iconFactory(attrs); 72 | const spin = this.#spin !== null && this.#spin !== undefined; 73 | const spinClass = spin ? 'spin' : ''; 74 | const svgElement = createElement(svgNode); 75 | 76 | if (spinClass && svgElement instanceof SVGElement) { 77 | svgElement.classList.add('spin'); 78 | } 79 | 80 | if (svgElement instanceof SVGElement) { 81 | svgElement.setAttribute('width', attrs.width); 82 | svgElement.setAttribute('height', attrs.height); 83 | } 84 | 85 | return html` 86 | 87 | ${svgElement} 88 | 89 | `; 90 | } 91 | 92 | static get styles() { 93 | return css` 94 | :host { 95 | display: inline-block; 96 | width: min-content; 97 | color: inherit; 98 | } 99 | 100 | .container svg { 101 | display: block; 102 | } 103 | 104 | @keyframes spin { 105 | 0% { 106 | transform: rotate(0deg); 107 | } 108 | 100% { 109 | transform: rotate(360deg); 110 | } 111 | } 112 | 113 | .spin { 114 | --spin-duration: 3s; 115 | animation: spin var(--spin-duration) linear infinite; 116 | } 117 | `; 118 | } 119 | } 120 | 121 | customElements.define('un-icon', IconElement); 122 | -------------------------------------------------------------------------------- /desktop/src/ui/common/input.css: -------------------------------------------------------------------------------- 1 | un-input { 2 | display: block; 3 | position: relative; 4 | pointer-events: none; 5 | 6 | .input-wrapper { 7 | position: relative; 8 | width: 100%; 9 | pointer-events: auto; 10 | 11 | &.small .clear-button { 12 | --button-height: 18px; 13 | } 14 | &.large .clear-button { 15 | --button-height: 28px; 16 | } 17 | 18 | .input-icon, 19 | .loading-icon { 20 | position: absolute; 21 | top: 50%; 22 | transform: translateY(-50%); 23 | color: currentColor; 24 | pointer-events: none; 25 | } 26 | &[data-icon='start'] .input { 27 | padding-inline-start: calc(var(--space-7)); 28 | } 29 | &[data-icon='end'] .input { 30 | padding-inline-end: calc(var(--space-7)); 31 | } 32 | &[data-icon='start'] .input-icon { 33 | inset-inline-start: var(--space-4); 34 | } 35 | &[data-icon='end'] .input-icon { 36 | inset-inline-end: var(--space-4); 37 | } 38 | 39 | .clear-button { 40 | position: absolute; 41 | right: var(--space-4); 42 | top: 0; 43 | 44 | &:hover { 45 | opacity: 1; 46 | button { 47 | background-color: transparent; 48 | } 49 | } 50 | &:disabled { 51 | opacity: 0; 52 | pointer-events: none; 53 | } 54 | } 55 | 56 | input:disabled + .clear-button { 57 | display: none; 58 | } 59 | } 60 | 61 | input { 62 | --input-height: 24px; 63 | width: 100%; 64 | height: var(--input-height); 65 | padding: 0 var(--space-4); 66 | border-radius: var(--rounded); 67 | border: 1px solid var(--input-border-color); 68 | border-bottom-color: color-mix( 69 | in srgb, 70 | var(--input-border-color) 100%, 71 | transparent 25% 72 | ); 73 | background-color: var(--input-bg-color); 74 | color: var(--input-text-color); 75 | font-family: inherit; 76 | font-size: inherit; 77 | line-height: var(--input-height); 78 | box-sizing: border-box; 79 | box-shadow: var(--input-shadows); 80 | 81 | &:focus { 82 | outline: var(--outline); 83 | outline-offset: var(--outline-offset-inputs); 84 | } 85 | 86 | &:disabled { 87 | opacity: 0.5; 88 | cursor: not-allowed; 89 | } 90 | 91 | &::placeholder { 92 | color: var(--input-placeholder-color); 93 | } 94 | 95 | &[type='number'] { 96 | &::-webkit-outer-spin-button, 97 | &::-webkit-inner-spin-button { 98 | -webkit-appearance: none; 99 | margin: 0; 100 | } 101 | } 102 | 103 | &.small { 104 | --input-height: 18px; 105 | font-size: var(--text-sm); 106 | } 107 | 108 | &.large { 109 | --input-height: 28px; 110 | } 111 | &.ghost { 112 | border-color: transparent; 113 | background-color: transparent; 114 | box-shadow: none; 115 | } 116 | &.flat { 117 | border-color: transparent; 118 | box-shadow: none; 119 | background-color: var(--input-bg-color-flat); 120 | } 121 | 122 | &[type='search'] { 123 | border-radius: 16px; 124 | padding-left: var(--space-5); 125 | padding-right: var(--space-8); 126 | 127 | &::-webkit-search-decoration, 128 | &::-webkit-search-cancel-button, 129 | &::-webkit-search-results-button, 130 | &::-webkit-search-results-decoration { 131 | -webkit-appearance: none; 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /desktop/src/ui/common/label.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit'; 2 | import { classMap } from 'lit/directives/class-map.js'; 3 | 4 | export type LabelSize = 'small' | 'medium'; 5 | export type LabelVariant = 'default' | 'required' | 'optional'; 6 | 7 | export class LabelElement extends LitElement { 8 | for: string = ''; 9 | text: string = ''; 10 | size: LabelSize = 'medium'; 11 | variant: LabelVariant = 'default'; 12 | 13 | static get properties() { 14 | return { 15 | for: { type: String }, 16 | text: { type: String }, 17 | size: { type: String }, 18 | variant: { type: String }, 19 | }; 20 | } 21 | 22 | constructor() { 23 | super(); 24 | this.size = 'medium'; 25 | this.variant = 'default'; 26 | } 27 | 28 | render() { 29 | const labelClasses = { 30 | label: true, 31 | [`label--${this.size}`]: this.size !== 'medium', 32 | [`label--${this.variant}`]: this.variant !== 'default', 33 | }; 34 | 35 | const hasSlotContent = this.hasChildNodes(); 36 | const showText = !hasSlotContent && this.text; 37 | 38 | // Determine if we need to show required/optional indicators 39 | const requiredIndicator = 40 | this.variant === 'required' 41 | ? html`` 42 | : ''; 43 | const optionalIndicator = 44 | this.variant === 'optional' 45 | ? html`` 48 | : ''; 49 | 50 | return html` 51 | 61 | `; 62 | } 63 | 64 | static get styles() { 65 | return css` 66 | :host { 67 | --label-color: var(--color-text-muted); 68 | } 69 | 70 | .label-content { 71 | display: flex; 72 | align-items: center; 73 | gap: var(--space-2); 74 | font-size: var(--text-sm); 75 | font-weight: 500; 76 | color: var(--label-color); 77 | } 78 | 79 | .required-indicator { 80 | color: var(--color-error-600); 81 | font-weight: 700; 82 | } 83 | 84 | .optional-indicator { 85 | color: var(--color-text-disabled); 86 | font-size: var(--text-xs); 87 | font-weight: 400; 88 | } 89 | 90 | .label--small .label-content { 91 | font-size: var(--text-xs); 92 | } 93 | `; 94 | } 95 | } 96 | 97 | customElements.define('un-label', LabelElement); 98 | -------------------------------------------------------------------------------- /desktop/src/ui/common/markdown-text.ts: -------------------------------------------------------------------------------- 1 | import { marked } from 'marked'; 2 | import { attachStyles } from '../../common/utils'; 3 | import { css } from 'lit'; 4 | 5 | class MarkdownText extends HTMLElement { 6 | constructor() { 7 | super(); 8 | const shadow = this.attachShadow({ mode: 'open' }); 9 | attachStyles(shadow, this.styles.toString()); 10 | } 11 | 12 | connectedCallback() { 13 | this.render(); 14 | this.observeContentChanges(); 15 | } 16 | 17 | async render() { 18 | const content = this.innerHTML; 19 | const strippedContent = content.replace(//g, ''); 20 | const renderedContent = await marked(strippedContent); 21 | this.shadowRoot.innerHTML = renderedContent; 22 | } 23 | 24 | observeContentChanges() { 25 | const observer = new MutationObserver(() => this.render()); 26 | observer.observe(this, { 27 | characterData: true, 28 | childList: true, 29 | subtree: true, 30 | }); 31 | } 32 | 33 | get styles() { 34 | return css` 35 | :host { 36 | max-width: 65ch; 37 | line-height: 1.5; 38 | } 39 | 40 | *:first-child { 41 | margin-top: 0; 42 | } 43 | 44 | *:last-child { 45 | margin-bottom: 0; 46 | } 47 | 48 | h1, 49 | h2, 50 | h3, 51 | h4, 52 | h5, 53 | h6 { 54 | margin-top: var(--space-7); 55 | margin-bottom: var(--space-4); 56 | line-height: var(--leading-tight); 57 | font-weight: 600; 58 | } 59 | 60 | p, 61 | ul, 62 | ol, 63 | blockquote { 64 | margin-bottom: var(--space-4); 65 | } 66 | 67 | ul, 68 | ol { 69 | padding-left: var(--space-8); 70 | } 71 | 72 | li { 73 | margin-bottom: var(--space-2); 74 | } 75 | 76 | a { 77 | color: var(--color-action-800); 78 | text-underline-offset: var(--space-1); 79 | } 80 | 81 | blockquote { 82 | border-left: 2px groove var(--color-text-muted); 83 | padding-left: var(--space-6); 84 | padding-top: var(--space-3); 85 | padding-bottom: var(--space-3); 86 | } 87 | 88 | blockquote *:last-child { 89 | margin-bottom: unset; 90 | } 91 | 92 | pre { 93 | font-family: var(--font-mono); 94 | padding: var(--space-4); 95 | background: var(--color-bg-page); 96 | border-radius: 6px; 97 | overflow-x: auto; 98 | margin-bottom: var(--space-4); 99 | } 100 | 101 | pre code { 102 | background: none; 103 | padding: 0; 104 | color: var(--color-text-default); 105 | } 106 | 107 | code { 108 | font-family: var(--font-mono); 109 | font-size: var(--text-sm); 110 | padding: var(--space-1) var(--space-2); 111 | background: var(--color-bg-page); 112 | border-radius: var(--rounded); 113 | } 114 | 115 | hr { 116 | border: none; 117 | border-top: 1px solid var(--color-border-muted); 118 | margin: var(--space-5) 0; 119 | } 120 | 121 | table { 122 | width: 100%; 123 | border-collapse: separate; 124 | border-spacing: 0; 125 | margin-bottom: var(--space-4); 126 | } 127 | 128 | th { 129 | font-weight: 600; 130 | text-align: left; 131 | background: var(--color-bg-container); 132 | border-bottom: 1px solid var(--color-border-default); 133 | } 134 | 135 | td { 136 | border-bottom: 1px solid var(--color-border-default); 137 | } 138 | 139 | th, 140 | td { 141 | padding: var(--space-3) var(--space-4); 142 | } 143 | 144 | tr:last-child td { 145 | border-bottom: none; 146 | } 147 | 148 | small { 149 | font-size: var(--text-sm); 150 | } 151 | 152 | kbd { 153 | font-family: var(--font-mono); 154 | font-size: var(--text-sm); 155 | padding: var(--space-1) var(--space-2); 156 | background: var(--color-bg-container); 157 | border: 1px solid var(--color-border-default); 158 | border-bottom-width: 2px; 159 | border-radius: var(--rounded); 160 | } 161 | 162 | sub, 163 | sup { 164 | font-size: var(--text-xs); 165 | } 166 | 167 | abbr { 168 | text-decoration: underline; 169 | text-decoration-style: dotted; 170 | cursor: help; 171 | } 172 | 173 | a { 174 | color: var(--color-action-600); 175 | } 176 | `; 177 | } 178 | } 179 | 180 | customElements.define('markdown-text', MarkdownText); 181 | -------------------------------------------------------------------------------- /desktop/src/ui/common/pages.ts: -------------------------------------------------------------------------------- 1 | import { HTMLTemplateResult } from 'lit'; 2 | import { IDisposable } from '../../common/disposable'; 3 | 4 | export interface Page extends IDisposable { 5 | id: string; 6 | render(): HTMLTemplateResult; 7 | } 8 | 9 | export class PageManager { 10 | pages = new Map(); 11 | 12 | register(page: Page) { 13 | this.pages.set(page.id, page); 14 | } 15 | 16 | render(id: string): HTMLTemplateResult | null { 17 | if (!this.pages.has(id)) { 18 | console.warn(`Page with id ${id} is not registered.`); 19 | return null; 20 | } 21 | 22 | return this.pages.get(id).render(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /desktop/src/ui/common/popover.css: -------------------------------------------------------------------------------- 1 | /* Resets */ 2 | .un-popover { 3 | margin: unset; 4 | inset: unset; 5 | border: unset; 6 | padding: unset; 7 | } 8 | 9 | /* Modal-like presentation */ 10 | .un-popover { 11 | border: 1px solid var(--color-border-default); 12 | background: var(--color-bg-content); 13 | border-radius: var(--rounded-lg); 14 | box-shadow: var(--shadow); 15 | margin: var(--space-6); 16 | } 17 | 18 | .un-popover .header { 19 | position: sticky; 20 | top: 0; 21 | background: var(--color-neutral-50); 22 | color: var(--color-text-muted); 23 | padding: var(--space-2) var(--space-4) var(--space-2) var(--space-6); 24 | display: flex; 25 | text-transform: uppercase; 26 | letter-spacing: 0.08ch; 27 | font-size: var(--text-xs); 28 | font-weight: 600; 29 | justify-content: space-between; 30 | align-items: center; 31 | } 32 | 33 | .un-popover .content { 34 | padding: var(--space-6); 35 | } 36 | 37 | /* Positioning logic */ 38 | .un-popover:popover-open { 39 | position-try-fallbacks: flip-block, flip-inline; 40 | } 41 | .un-popover[data-position='top'] { 42 | position-area: top; 43 | justify-self: anchor-center; 44 | } 45 | .un-popover[data-position='right'] { 46 | position-area: right; 47 | align-self: anchor-center; 48 | } 49 | .un-popover[data-position='bottom'] { 50 | position-area: bottom; 51 | justify-self: anchor-center; 52 | } 53 | .un-popover[data-position='left'] { 54 | position-area: left; 55 | align-self: anchor-center; 56 | } 57 | -------------------------------------------------------------------------------- /desktop/src/ui/common/popover.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import './popover.css'; 3 | 4 | export type PopoverPosition = 'top' | 'right' | 'bottom' | 'left'; 5 | 6 | /** 7 | * A generic, composable popover custom element using the native Popover API. 8 | * 9 | * @element un-popover 10 | * @attr {string} anchor - The id of the anchor element this popover is positioned against. 11 | * @attr {PopoverPosition} position - The position of the popover relative to the anchor (top, right, bottom, left). 12 | * @slot - Default slot for popover content. 13 | * 14 | * Usage example: 15 | * 16 | * ... 17 | */ 18 | export class PopoverElement extends HTMLElement { 19 | static observedAttributes = ['anchor', 'position']; 20 | 21 | constructor() { 22 | super(); 23 | this.setAttribute('popover', ''); 24 | this.classList.add('un-popover'); 25 | } 26 | 27 | attributeChangedCallback( 28 | name: string, 29 | oldValue: string | null, 30 | newValue: string | null 31 | ) { 32 | if (oldValue === newValue) return; 33 | setTimeout(() => this.render(), 0); 34 | } 35 | 36 | /** 37 | * Ensures the anchor element exists and updates popover positioning styles. 38 | * Throws if the anchor id is defined, but no element with that id is found. 39 | */ 40 | #updateAnchorPositioning() { 41 | const anchor = this.getAttribute('anchor'); 42 | const position = this.getAttribute('position'); 43 | 44 | if (!anchor) return; 45 | const anchorEl = document.getElementById(anchor); 46 | if (!anchorEl) { 47 | const msg = `[un-popover] Anchor element with id "${anchor}" not found.`; 48 | console.error(msg); 49 | this.removeAttribute('data-position'); // Remove positioning if anchor is missing 50 | return; 51 | } 52 | 53 | const anchorNameValue = `--${anchor}`; 54 | const style = anchorEl.style as CSSStyleDeclaration & { 55 | anchorName?: string; 56 | }; 57 | style.anchorName = anchorNameValue; 58 | (this.style as any).positionAnchor = anchorNameValue; 59 | this.setAttribute('data-position', position ?? 'top'); 60 | } 61 | 62 | render() { 63 | this.#updateAnchorPositioning(); 64 | render(this.template, this); 65 | } 66 | 67 | get template() { 68 | return html`
`; 69 | } 70 | } 71 | 72 | customElements.define('un-popover', PopoverElement); 73 | -------------------------------------------------------------------------------- /desktop/src/ui/common/styles/global.css: -------------------------------------------------------------------------------- 1 | @import url(./reset.css); 2 | @import url(./theme.css); 3 | 4 | :root { 5 | /* --font-sans: 'iA Writer Quattro', 'iA Writer Quattro S', system-ui, sans-serif; */ 6 | --font-sans: ui-sans-serif, system-ui, 'Roboto', sans-serif; 7 | --font-mono: 'iA Writer Mono', monospace; 8 | 9 | /* Font size */ 10 | /* --text-scale: 1.125; */ 11 | --text-base: 13px; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | 18 | a { 19 | color: currentColor; 20 | } 21 | 22 | a:hover { 23 | color: var(--color-action-800); 24 | } 25 | 26 | html, 27 | body { 28 | width: 100%; 29 | height: 100%; 30 | padding: 0; 31 | margin: 0; 32 | /* overflow: hidden; */ 33 | /* overscroll-behavior: contain; */ 34 | user-select: none; 35 | } 36 | 37 | body, 38 | input, 39 | textarea, 40 | input, 41 | dialog, 42 | :popover-open { 43 | color: var(--color-text-default); 44 | font-family: var(--font-sans); 45 | font-size: var(--text-base); 46 | 47 | font-optical-sizing: auto; 48 | font-weight: 400; 49 | font-style: normal; 50 | /* -webkit-font-smoothing: antialiased; */ 51 | /* -moz-osx-font-smoothing: grayscale; */ 52 | /* font-smooth: always; */ 53 | line-height: 1.3; 54 | } 55 | 56 | input { 57 | color: var(--color-text-default); 58 | border: 1px solid var(--color-border); 59 | padding: 6px var(--space-4); 60 | background: var(--color-bg-input); 61 | border-radius: var(--rounded); 62 | /* outline: none; */ 63 | transition: all 0.2s ease; 64 | } 65 | 66 | hr { 67 | border: none; 68 | border-bottom: 1px solid var(--color-border-muted); 69 | margin: var(--space-3) 0; 70 | } 71 | 72 | kbd { 73 | display: inline-flex; 74 | align-items: center; 75 | justify-content: center; 76 | font-family: var(--font-mono); 77 | padding: var(--space-2) var(--space-4); 78 | background: var(--color-bg-container); 79 | border: 1px solid var(--color-border-default); 80 | border-bottom-width: 2px; 81 | border-radius: var(--rounded); 82 | } 83 | 84 | form { 85 | display: flex; 86 | flex-direction: column; 87 | gap: var(--space-8); 88 | } 89 | 90 | fieldset { 91 | padding: 0; 92 | margin: 0; 93 | border: none; 94 | display: flex; 95 | flex-direction: column; 96 | gap: var(--space-6); 97 | } 98 | 99 | legend { 100 | font-size: var(--text-sm); 101 | text-transform: uppercase; 102 | letter-spacing: 0.5px; 103 | padding-right: var(--space-4); 104 | color: var(--color-text-muted); 105 | } 106 | 107 | fieldset + fieldset { 108 | border-top: 1px solid var(--color-border-default); 109 | padding-top: var(--space-6); 110 | } 111 | 112 | body { 113 | background: var(--color-bg-page); 114 | color: var(--color-text-default); 115 | -webkit-overflow-scrolling: touch; 116 | display: flex; 117 | flex-direction: column; 118 | /* position: fixed; */ 119 | padding-bottom: env(safe-area-inset-bottom); 120 | } 121 | 122 | .page { 123 | width: 100%; 124 | max-width: 900px; 125 | padding: 0 12px; 126 | margin: 0 auto; 127 | } 128 | 129 | .hidden { 130 | display: none; 131 | } 132 | 133 | h1, 134 | h2, 135 | h3, 136 | h4, 137 | h5, 138 | h6 { 139 | font-weight: 500; 140 | } 141 | 142 | h1 { 143 | font-size: var(--text-3xl); 144 | } 145 | 146 | h2 { 147 | font-size: var(--text-2xl); 148 | } 149 | 150 | h3 { 151 | font-size: var(--text-xl); 152 | } 153 | 154 | h4 { 155 | font-size: var(--text-lg); 156 | } 157 | 158 | h5 { 159 | font-size: var(--text-base); 160 | } 161 | 162 | h6 { 163 | font-size: var(--text-sm); 164 | text-transform: uppercase; 165 | letter-spacing: 0.5px; 166 | } 167 | 168 | input.error { 169 | border-color: var(--color-error); 170 | } 171 | 172 | .error-text { 173 | color: var(--color-error); 174 | } 175 | 176 | /* Text utility classes */ 177 | 178 | .text-xs { 179 | font-size: var(--text-xs); 180 | } 181 | .text-sm { 182 | font-size: var(--text-sm); 183 | } 184 | .text-base { 185 | font-size: var(--text-base); 186 | } 187 | .text-lg { 188 | font-size: var(--text-lg); 189 | } 190 | .text-xl { 191 | font-size: var(--text-xl); 192 | } 193 | .text-2xl { 194 | font-size: var(--text-2xl); 195 | } 196 | .text-3xl { 197 | font-size: var(--text-3xl); 198 | } 199 | .text-4xl { 200 | font-size: var(--text-4xl); 201 | } 202 | .text-5xl { 203 | font-size: var(--text-5xl); 204 | } 205 | 206 | .text-disabled { 207 | color: var(--color-text-disabled); 208 | } 209 | .text-muted { 210 | color: var(--color-text-muted); 211 | } 212 | .text-strong { 213 | color: var(--color-text-strong); 214 | } 215 | -------------------------------------------------------------------------------- /desktop/src/ui/common/styles/markdown.css: -------------------------------------------------------------------------------- 1 | [data-format='markdown'] { 2 | max-width: 65ch; 3 | line-height: 1.5; 4 | } 5 | 6 | [data-format='markdown'] h1, 7 | [data-format='markdown'] h2, 8 | [data-format='markdown'] h3, 9 | [data-format='markdown'] h4, 10 | [data-format='markdown'] h5, 11 | [data-format='markdown'] h6 { 12 | margin-top: var(--space-8); 13 | margin-bottom: var(--space-4); 14 | line-height: 1.3; 15 | font-weight: 600; 16 | } 17 | 18 | [data-format='markdown'] p, 19 | [data-format='markdown'] ul, 20 | [data-format='markdown'] ol, 21 | [data-format='markdown'] blockquote { 22 | margin-bottom: 1.5em; 23 | } 24 | 25 | [data-format='markdown'] ul, 26 | [data-format='markdown'] ol { 27 | padding-left: 1.5em; 28 | } 29 | 30 | [data-format='markdown'] li { 31 | margin-bottom: 0.5em; 32 | } 33 | 34 | [data-format='markdown'] a { 35 | text-underline-offset: 0.2em; 36 | } 37 | 38 | [data-format='markdown'] blockquote { 39 | border-left: 2px groove var(--color-text-muted); 40 | padding-left: var(--space-6); 41 | padding-top: var(--space-3); 42 | padding-bottom: var(--space-3); 43 | } 44 | 45 | [data-format='markdown'] blockquote *:last-child { 46 | margin-bottom: unset; 47 | } 48 | 49 | [data-format='markdown'] pre { 50 | font-family: var(--font-mono); 51 | padding: 1.5em; 52 | background: var(--color-bg-page); 53 | border-radius: 6px; 54 | overflow-x: auto; 55 | margin-bottom: 1.5em; 56 | } 57 | 58 | [data-format='markdown'] pre code { 59 | background: none; 60 | padding: 0; 61 | color: var(--color-text-default); 62 | } 63 | 64 | [data-format='markdown'] code { 65 | font-family: var(--font-mono); 66 | font-size: var(--text-sm); 67 | padding: 0.2em 0.4em; 68 | background: var(--color-bg-page); 69 | border-radius: 3px; 70 | } 71 | 72 | [data-format='markdown'] hr { 73 | border: none; 74 | border-top: 1px solid var(--color-border-muted); 75 | margin: var(--space-9) 0; 76 | } 77 | 78 | [data-format='markdown'] table { 79 | width: 100%; 80 | border-collapse: separate; 81 | border-spacing: 0; 82 | margin-bottom: 1.5em; 83 | } 84 | 85 | [data-format='markdown'] th { 86 | font-weight: 600; 87 | text-align: left; 88 | background: var(--color-bg-container); 89 | border-bottom: 2px solid var(--color-border-default); 90 | } 91 | 92 | [data-format='markdown'] td { 93 | border-bottom: 1px solid var(--color-border-default); 94 | } 95 | 96 | [data-format='markdown'] th, 97 | [data-format='markdown'] td { 98 | padding: var(--space-3) var(--space-4); 99 | } 100 | 101 | [data-format='markdown'] tr:last-child td { 102 | border-bottom: none; 103 | } 104 | 105 | [data-format='markdown'] mark { 106 | background: var(--color-highlight-300); 107 | padding: 0.2em 0.4em; 108 | border-radius: 3px; 109 | } 110 | 111 | [data-format='markdown'] small { 112 | font-size: var(--text-sm); 113 | } 114 | 115 | [data-format='markdown'] kbd { 116 | font-family: var(--font-mono); 117 | font-size: var(--text-sm); 118 | padding: 0.2em 0.4em; 119 | background: var(--color-bg-container); 120 | border: 1px solid var(--color-border-default); 121 | border-bottom-width: 2px; 122 | border-radius: 4px; 123 | } 124 | 125 | [data-format='markdown'] sub, 126 | [data-format='markdown'] sup { 127 | font-size: var(--text-xs); 128 | } 129 | 130 | [data-format='markdown'] abbr { 131 | text-decoration: underline; 132 | text-decoration-style: dotted; 133 | cursor: help; 134 | } 135 | -------------------------------------------------------------------------------- /desktop/src/ui/common/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | A nice clean reset from https://piccalil.li/blog/a-more-modern-css-reset/ w some slight mods. 3 | */ 4 | 5 | /* Box sizing rules */ 6 | *, 7 | *::before, 8 | *::after { 9 | box-sizing: border-box; 10 | } 11 | 12 | /* Prevent font size inflation */ 13 | html { 14 | -moz-text-size-adjust: none; 15 | -webkit-text-size-adjust: none; 16 | text-size-adjust: none; 17 | } 18 | 19 | /* Remove default margin in favour of better control in authored CSS */ 20 | body, 21 | h1, 22 | h2, 23 | h3, 24 | h4, 25 | h5, 26 | h6, 27 | p, 28 | figure, 29 | blockquote, 30 | dl, 31 | dd { 32 | margin: 0; 33 | } 34 | 35 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ 36 | ul[role='list'], 37 | ol[role='list'] { 38 | list-style: none; 39 | } 40 | 41 | ul, 42 | ol, 43 | li { 44 | padding: 0; 45 | margin: 0; 46 | } 47 | 48 | /* Set core body defaults */ 49 | body { 50 | min-height: 100vh; 51 | line-height: 1.5; 52 | } 53 | 54 | /* Set shorter line heights on headings and interactive elements */ 55 | h1, 56 | h2, 57 | h3, 58 | h4, 59 | h5, 60 | h6, 61 | button, 62 | input, 63 | label { 64 | line-height: 1.1; 65 | } 66 | 67 | /* Balance text wrapping on headings */ 68 | h1, 69 | h2, 70 | h3, 71 | h4, 72 | h5, 73 | h6 { 74 | text-wrap: balance; 75 | } 76 | 77 | /* Make images easier to work with */ 78 | img, 79 | picture { 80 | max-width: 100%; 81 | display: block; 82 | } 83 | 84 | /* Inherit fonts for inputs and buttons */ 85 | input, 86 | button, 87 | textarea, 88 | select { 89 | font-family: inherit; 90 | font-size: inherit; 91 | } 92 | 93 | /* Make sure textareas without a rows attribute are not tiny */ 94 | textarea:not([rows]) { 95 | min-height: 10em; 96 | } 97 | 98 | /* Anything that has been anchored to should have extra scroll margin */ 99 | :target { 100 | scroll-margin-block: 5ex; 101 | } 102 | -------------------------------------------------------------------------------- /desktop/src/ui/modals/bug-modal.ts: -------------------------------------------------------------------------------- 1 | import { appendEl, attachStyles, createEl } from '../../common/utils'; 2 | import { BUG_REPORT_URL } from '../../constants'; 3 | import { ModalElement } from '../../modals/modal-element'; 4 | 5 | export class BugModal extends ModalElement { 6 | constructor() { 7 | super({ 8 | title: 'Report a bug', 9 | size: 'full', 10 | padding: 'none', 11 | }); 12 | 13 | const styles = /*css*/ ` 14 | :host { 15 | width: 100%; 16 | height: 100%; 17 | } 18 | 19 | iframe { 20 | width: 100%; 21 | height: 100%; 22 | border: 0; 23 | } 24 | `; 25 | 26 | const shadow = this.attachShadow({ mode: 'open' }); 27 | attachStyles(shadow, styles); 28 | appendEl(shadow, createEl('iframe', { src: BUG_REPORT_URL })); 29 | } 30 | } 31 | 32 | customElements.define('bug-modal', BugModal); 33 | -------------------------------------------------------------------------------- /desktop/src/ui/modals/new-workspace-modal.ts: -------------------------------------------------------------------------------- 1 | import { html, render, TemplateResult } from 'lit'; 2 | import { ModalElement, ModalOptions } from '../../modals/modal-element'; 3 | import { dependencies } from '../../common/dependencies'; 4 | import { WorkspaceModel } from '../../models/workspace-model'; 5 | import '../common/input'; 6 | import '../common/button'; 7 | import '../common/label'; 8 | 9 | export class NewWorkspaceModal extends ModalElement { 10 | #workspaceModel!: WorkspaceModel; 11 | #workspaceName = ''; 12 | #saving = false; 13 | 14 | constructor() { 15 | super({ 16 | title: 'New Workspace', 17 | } as ModalOptions); 18 | this.#workspaceModel = 19 | dependencies.resolve('WorkspaceModel'); 20 | } 21 | 22 | connectedCallback() { 23 | this.render(); 24 | setTimeout(() => { 25 | const firstInput = this.querySelector('input'); 26 | if (firstInput) (firstInput as HTMLElement).focus(); 27 | }, 0); 28 | } 29 | 30 | #handleInput = (e: Event) => { 31 | const target = e.target as HTMLInputElement; 32 | this.#workspaceName = target.value; 33 | this.render(); 34 | }; 35 | 36 | #handleSave = async (e: Event) => { 37 | e.preventDefault(); 38 | if (!this.#workspaceName.trim()) return; 39 | this.#saving = true; 40 | this.render(); 41 | try { 42 | const ws = await this.#workspaceModel.create(this.#workspaceName.trim()); 43 | if (ws?.id) { 44 | this.#workspaceModel.activate(ws.id); 45 | } 46 | this.close(); 47 | } finally { 48 | this.#saving = false; 49 | } 50 | }; 51 | 52 | private get template(): TemplateResult { 53 | return html` 54 |
55 |
56 |

57 | Workspaces are places for you to focus on different 58 | topics or goals 59 | eg. "Personal", "Work", "Summer Road Trip" or "Kitchen 61 | Renovation". 63 | Think of them more like a room you go to, rather than a conversation 64 | you start. 65 |

66 |
67 |
68 | Give your workspace a name 69 | 75 |
76 |
77 | 78 | 79 | 85 | 86 |
87 |
88 | `; 89 | } 90 | 91 | render() { 92 | render(this.template, this); 93 | } 94 | } 95 | 96 | customElements.define('new-workspace-modal', NewWorkspaceModal); 97 | -------------------------------------------------------------------------------- /desktop/src/ui/modals/settings-modal/applets-section.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | 3 | export const appletsSectionDef = { 4 | key: 'applets', 5 | label: 'Applets', 6 | render: () => html``, 7 | }; 8 | 9 | export class AppletsSection extends HTMLElement { 10 | #appletsDir: undefined | string; 11 | 12 | connectedCallback() { 13 | this.render(); 14 | system.localAppletsDirectory().then((dir) => { 15 | this.#appletsDir = dir; 16 | this.render(); 17 | }); 18 | } 19 | 20 | render() { 21 | if (!this.#appletsDir) { 22 | return render(html``, this); 23 | } 24 | 25 | render( 26 | html` 27 |
28 |

Applets

29 |
30 | 31 | Local applets 32 | 33 |

34 | You can add web applets directly from your device to your 35 | workspace. To add a web applet, you put your project in the folder 36 | shown below. It'll look for directories with both an index.html 37 | and manifest.json file. 38 |

39 |
40 |

41 | Your local applets are located at: 44 | 45 |

46 |

47 | { 49 | system.openLocalAppletsDirectory(); 50 | }} 51 | >Open directory 53 |

54 |
55 | `, 56 | this 57 | ); 58 | } 59 | } 60 | 61 | customElements.define('applets-section', AppletsSection); 62 | -------------------------------------------------------------------------------- /desktop/src/ui/modals/settings-modal/index.css: -------------------------------------------------------------------------------- 1 | settings-modal { 2 | display: flex; 3 | flex-direction: column; 4 | gap: var(--space-6); 5 | } 6 | 7 | .settings-modal-layout { 8 | display: flex; 9 | flex-direction: row; 10 | gap: var(--space-6); 11 | } 12 | 13 | .settings-menu { 14 | min-width: 180px; 15 | max-width: 220px; 16 | border-right: 1px solid var(--color-border-200); 17 | height: 100%; 18 | display: flex; 19 | flex-direction: column; 20 | gap: var(--space-2); 21 | 22 | un-button { 23 | width: 100%; 24 | button { 25 | justify-content: left; 26 | width: 100%; 27 | } 28 | &.active, 29 | &:hover { 30 | button { 31 | background: var(--color-bg-page); 32 | border-radius: var(--rounded); 33 | } 34 | } 35 | } 36 | } 37 | 38 | .settings-content { 39 | width: 72ch; 40 | flex: 1; 41 | display: flex; 42 | flex-direction: column; 43 | margin-top: var(--space-2); 44 | gap: var(--space-6); 45 | } 46 | 47 | .setting-row { 48 | display: flex; 49 | flex-direction: column; 50 | gap: var(--space-2); 51 | } 52 | 53 | .setting-group { 54 | display: flex; 55 | flex-direction: column; 56 | gap: var(--space-6); 57 | } 58 | 59 | .model-error { 60 | display: flex; 61 | align-items: center; 62 | gap: var(--space-2); 63 | border-radius: var(--rounded); 64 | padding: var(--space-2) var(--space-4); 65 | color: var(--color-error-600); 66 | background: var(--color-error-0); 67 | } 68 | 69 | .model-loading { 70 | display: flex; 71 | align-items: center; 72 | gap: var(--space-2); 73 | } 74 | -------------------------------------------------------------------------------- /desktop/src/ui/modals/settings-modal/index.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import { ModalElement, ModalOptions } from '../../../modals/modal-element'; 3 | 4 | import './index.css'; 5 | 6 | import { appletsSectionDef } from './applets-section'; 7 | import { workspaceSectionDef } from './workspace-section'; 8 | import { globalSectionDef } from './global-section'; 9 | import { shortcutsSectionDef } from './shortcuts-section'; 10 | 11 | const settingsSections = [ 12 | workspaceSectionDef, 13 | globalSectionDef, 14 | shortcutsSectionDef, 15 | appletsSectionDef, 16 | ] as const; 17 | 18 | type SectionKey = (typeof settingsSections)[number]['key']; 19 | 20 | /** 21 | * SettingsModal: A modular, extensible modal for application settings. 22 | * Sections declare their own metadata and render logic. 23 | * The modal coordinates section switching and focus, with minimal internal state. 24 | */ 25 | export class SettingsModal extends ModalElement { 26 | #section: SectionKey = 'workspace'; 27 | static #sectionMap = new Map(settingsSections.map((s) => [s.key, s])); 28 | 29 | constructor() { 30 | super({ title: 'Settings', size: 'full-height' } as ModalOptions); 31 | } 32 | 33 | connectedCallback() { 34 | this.addEventListener('modal-open', this.#onModalOpen); 35 | this.render(); 36 | } 37 | 38 | disconnectedCallback() { 39 | this.removeEventListener('modal-open', this.#onModalOpen); 40 | } 41 | 42 | #onModalOpen = (event: CustomEvent) => { 43 | const options = event.detail?.options; 44 | if ( 45 | options && 46 | typeof options.section === 'string' && 47 | SettingsModal.#sectionMap.has(options.section) 48 | ) { 49 | this.#section = options.section; 50 | this.render(); 51 | } 52 | }; 53 | 54 | #handleMenuClick = (section: SectionKey) => { 55 | this.#section = section; 56 | this.render(); 57 | }; 58 | 59 | get template() { 60 | const section = SettingsModal.#sectionMap.get(this.#section); 61 | return html` 62 |
63 | 75 |
76 |
${section?.render() ?? null}
77 |
78 |
79 | `; 80 | } 81 | 82 | render() { 83 | render(this.template, this); 84 | } 85 | } 86 | 87 | customElements.define('settings-modal', SettingsModal); 88 | -------------------------------------------------------------------------------- /desktop/src/ui/modals/settings-modal/shortcuts-section.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import { dependencies } from '../../../common/dependencies'; 3 | 4 | export const shortcutsSectionDef = { 5 | key: 'shortcuts', 6 | label: 'Shortcuts', 7 | render: () => html``, 8 | }; 9 | 10 | type ShortcutEntry = { 11 | id: string; 12 | label: string; 13 | keys: string; 14 | description?: string; 15 | }; 16 | 17 | export class ShortcutsSection extends HTMLElement { 18 | #shortcutsList: ShortcutEntry[] = []; 19 | #shortcutService: any; 20 | 21 | connectedCallback() { 22 | this.#shortcutService = dependencies.resolve('ShortcutService'); 23 | if (this.#shortcutService) { 24 | // Adapt the output to ShortcutEntry[] 25 | this.#shortcutsList = this.#shortcutService 26 | .listShortcuts() 27 | .filter((s: any) => !!s.description) 28 | .map((s: any, i: number) => ({ 29 | id: String(i), 30 | label: s.keys, 31 | keys: s.keys, 32 | description: s.description, 33 | })); 34 | } 35 | this.render(); 36 | const focusableElements = this.querySelectorAll('input, textarea, select'); 37 | if (focusableElements.length > 0) { 38 | (focusableElements[0] as HTMLElement).focus(); 39 | } 40 | } 41 | 42 | /** 43 | * Prettify a shortcut string for macOS (e.g. 'Meta+Shift+T' -> '⌘⇧T') 44 | */ 45 | prettifyShortcut(keys: string): string { 46 | if (!keys) return ''; 47 | // macOS symbols 48 | const macMap: Record = { 49 | META: '⌘', 50 | CMD: '⌘', 51 | COMMAND: '⌘', 52 | CTRL: '⌃', 53 | CONTROL: '⌃', 54 | SHIFT: '⇧', 55 | ALT: '⌥', 56 | OPTION: '⌥', 57 | ESC: '⎋', 58 | ESCAPE: '⎋', 59 | ENTER: '⏎', 60 | RETURN: '⏎', 61 | TAB: '⇥', 62 | BACKSPACE: '⌫', 63 | DELETE: '⌦', 64 | UP: '↑', 65 | DOWN: '↓', 66 | LEFT: '←', 67 | RIGHT: '→', 68 | SPACE: '␣', 69 | }; 70 | return keys 71 | .split('+') 72 | .map((k) => { 73 | const upper = k.trim().toUpperCase(); 74 | return macMap[upper] || k.trim(); 75 | }) 76 | .join(''); 77 | } 78 | 79 | render() { 80 | render( 81 | html` 82 |
83 |

Shortcuts

84 | 85 | 86 | ${this.#shortcutsList.map( 87 | (s) => html` 88 | 89 | 94 | 95 | 96 | ` 97 | )} 98 | 99 |
90 | ${this.prettifyShortcut(s.keys) 91 | .split('') 92 | .map((char) => html`${char}`)} 93 | ${s.description}
100 |
101 | `, 102 | this 103 | ); 104 | } 105 | } 106 | 107 | customElements.define('shortcuts-section', ShortcutsSection); 108 | -------------------------------------------------------------------------------- /desktop/src/ui/modals/settings-modal/workspace-section.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import { dependencies } from '../../../common/dependencies'; 3 | 4 | export const workspaceSectionDef = { 5 | key: 'workspace', 6 | label: 'Workspace', 7 | render: () => html``, 8 | }; 9 | 10 | export class WorkspaceSection extends HTMLElement { 11 | #workspaceModel: any; 12 | #modalService: any; 13 | #workspace: any; 14 | #newWorkspaceName: string; 15 | 16 | constructor() { 17 | super(); 18 | this.#workspaceModel = null; 19 | this.#modalService = null; 20 | this.#workspace = null; 21 | this.#newWorkspaceName = ''; 22 | } 23 | 24 | connectedCallback() { 25 | this.#workspaceModel = dependencies.resolve('WorkspaceModel'); 26 | this.#modalService = dependencies.resolve('ModalService'); 27 | if (this.#workspaceModel) { 28 | const id = this.#workspaceModel.activeWorkspaceId; 29 | this.#workspace = this.#workspaceModel.get(id); 30 | this.#newWorkspaceName = this.#workspace?.title || ''; 31 | } 32 | this.render(); 33 | setTimeout(() => { 34 | const firstInput = this.querySelector( 35 | 'input, textarea, select, un-textarea' 36 | ); 37 | if (firstInput) (firstInput as HTMLElement).focus(); 38 | }, 0); 39 | } 40 | 41 | #handleNameChange = (event: InputEvent) => { 42 | const newName = (event.target as HTMLInputElement).value; 43 | this.#newWorkspaceName = newName; 44 | if ( 45 | newName.trim() && 46 | this.#workspace && 47 | newName !== this.#workspace.title 48 | ) { 49 | this.#workspaceModel.setTitle(newName.trim()); 50 | } 51 | this.render(); 52 | }; 53 | 54 | #handleDelete = () => { 55 | if (!this.#workspace) return; 56 | const wsTitle = this.#workspace.title; 57 | const wsId = this.#workspace.id; 58 | this.#modalService.open('workspace-delete', { 59 | 'workspace-id': wsId, 60 | 'workspace-title': wsTitle, 61 | }); 62 | }; 63 | 64 | render() { 65 | render( 66 | html` 67 |
68 |

Current Workspace

69 |
These settings apply to the current workspace.
70 |
71 | Workspace Name 72 | 78 |
79 |
80 | Delete Workspace 86 |
87 |
88 | `, 89 | this 90 | ); 91 | } 92 | } 93 | 94 | customElements.define('workspace-section', WorkspaceSection); 95 | -------------------------------------------------------------------------------- /desktop/src/ui/modals/workspace-delete-modal.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import { dependencies } from '../../common/dependencies'; 3 | import { ModalService } from '../../modals/modal-service'; 4 | import { WorkspaceModel } from '../../models/workspace-model'; 5 | import { ModalElement, ModalOptions } from '../../modals/modal-element'; 6 | 7 | export class WorkspaceDeleteModal extends ModalElement { 8 | workspaceId = ''; 9 | workspaceTitle = ''; 10 | 11 | #workspaceModel = dependencies.resolve('WorkspaceModel'); 12 | #modalService = dependencies.resolve('ModalService'); 13 | 14 | constructor() { 15 | super({ 16 | title: 'Delete Workspace', 17 | size: 'auto', 18 | padding: 'auto', 19 | blocking: true, 20 | position: 'center', 21 | } as ModalOptions); 22 | this.addEventListener('modal-open', this.#handleOpen); 23 | } 24 | 25 | disconnectedCallback() { 26 | this.removeEventListener('modal-open', this.#handleOpen); 27 | } 28 | 29 | #handleOpen = (e: CustomEvent) => { 30 | const { options } = e.detail || {}; 31 | if (options['workspace-id']) { 32 | this.workspaceId = options['workspace-id']; 33 | } 34 | if (options['workspace-title']) { 35 | this.workspaceTitle = options['workspace-title']; 36 | } 37 | this.render(); 38 | }; 39 | 40 | #handleCancel = () => { 41 | this.close(); 42 | }; 43 | 44 | #handleDelete = () => { 45 | this.#workspaceModel.delete(this.workspaceId); 46 | this.close(); 47 | this.#modalService.close('settings'); 48 | }; 49 | 50 | render() { 51 | const template = html` 52 |
53 |

54 | Are you sure you want to delete 55 | ${this.workspaceTitle}? This action cannot be undone. 56 |

57 |
58 | 59 | Cancel 60 | 61 | 62 | Delete 63 | 64 |
65 |
66 | `; 67 | 68 | render(template, this); 69 | } 70 | } 71 | 72 | customElements.define('workspace-delete-modal', WorkspaceDeleteModal); 73 | -------------------------------------------------------------------------------- /desktop/src/ui/processes/process-frame.css: -------------------------------------------------------------------------------- 1 | process-frame { 2 | border: 1px solid var(--color-border); 3 | display: flex; 4 | flex-direction: column; 5 | border-radius: var(--rounded); 6 | overflow: hidden; 7 | flex-grow: 1; 8 | } 9 | 10 | process-frame .process-header { 11 | display: flex; 12 | gap: var(--space-4); 13 | padding: var(--space-2) var(--space-5); 14 | align-items: center; 15 | font-weight: 500; 16 | font-size: var(--text-sm); 17 | border-bottom: 1px solid var(--color-border); 18 | background: var(--color-neutral-100); 19 | } 20 | 21 | process-frame .process-header img { 22 | height: 16px; 23 | } 24 | -------------------------------------------------------------------------------- /desktop/src/ui/processes/process-frame.ts: -------------------------------------------------------------------------------- 1 | import { ProcessContainer } from '@unternet/kernel'; 2 | import { guard } from 'lit/directives/guard.js'; 3 | 4 | import './process-frame.css'; 5 | import './process-view'; 6 | import { getResourceIcon } from '../../common/utils'; 7 | import { html, HTMLTemplateResult, render } from 'lit'; 8 | 9 | class ProcessFrame extends HTMLElement { 10 | set process(process: ProcessContainer) { 11 | this.render(process); 12 | } 13 | 14 | handleResume(process: ProcessContainer) { 15 | process.resume(); 16 | this.render(process); 17 | } 18 | 19 | render(process: ProcessContainer) { 20 | const iconSrc = getResourceIcon(process); 21 | const iconTemplate = html``; 22 | 23 | let bodyTemplate: HTMLTemplateResult; 24 | 25 | if (process.status === 'running') { 26 | bodyTemplate = html``; 27 | } else { 28 | bodyTemplate = html``; 31 | } 32 | 33 | const template = guard( 34 | [process.pid, process.status], 35 | () => html` 36 |
${iconTemplate} ${process.title}
37 | ${bodyTemplate} 38 | ` 39 | ); 40 | 41 | render(template, this); 42 | } 43 | } 44 | 45 | customElements.define('process-frame', ProcessFrame); 46 | -------------------------------------------------------------------------------- /desktop/src/ui/processes/process-view.css: -------------------------------------------------------------------------------- 1 | process-view { 2 | display: flex; 3 | flex-direction: column; 4 | flex-grow: 1; 5 | } 6 | 7 | process-view > * { 8 | flex-grow: 1; 9 | } 10 | -------------------------------------------------------------------------------- /desktop/src/ui/processes/process-view.ts: -------------------------------------------------------------------------------- 1 | import { ProcessContainer } from '@unternet/kernel'; 2 | import './process-view.css'; 3 | 4 | class ProcessView extends HTMLElement { 5 | #process: ProcessContainer | null = null; 6 | 7 | set process(process: ProcessContainer | null) { 8 | if (this.#process && this.#process.unmount) { 9 | this.#process.unmount(); 10 | } 11 | 12 | this.#process = process; 13 | 14 | if (this.#process && this.#process.mount) { 15 | this.#process.mount(this); 16 | } 17 | } 18 | 19 | disconnectedCallback() { 20 | if (this.#process?.unmount) { 21 | this.#process.unmount(); 22 | } 23 | } 24 | } 25 | 26 | customElements.define('process-view', ProcessView); 27 | -------------------------------------------------------------------------------- /desktop/src/ui/thread/idle-screen.css: -------------------------------------------------------------------------------- 1 | idle-screen { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | align-items: center; 6 | justify-self: center; 7 | } 8 | 9 | idle-screen input { 10 | margin: 0 auto; 11 | width: 100%; 12 | max-width: 530px; 13 | background: var(--color-neutral-0); 14 | padding: var(--space-5) var(--space-6); 15 | } 16 | -------------------------------------------------------------------------------- /desktop/src/ui/thread/idle-screen.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import './idle-screen.css'; 3 | 4 | export class IdleScreenElement extends HTMLElement { 5 | connectedCallback() { 6 | this.render(); 7 | } 8 | 9 | render() { 10 | const template = html` 11 | 12 | `; 13 | 14 | render(template, this); 15 | } 16 | } 17 | 18 | customElements.define('idle-screen', IdleScreenElement); 19 | -------------------------------------------------------------------------------- /desktop/src/ui/thread/thread-view.css: -------------------------------------------------------------------------------- 1 | thread-view { 2 | width: 100%; 3 | flex-grow: 1; 4 | overflow-y: auto; 5 | } 6 | 7 | thread-view .message-container:last-child { 8 | min-height: 100%; 9 | } 10 | 11 | thread-view idle-screen { 12 | height: 100%; 13 | flex-grow: 1; 14 | } 15 | 16 | thread-view .message-container { 17 | display: flex; 18 | flex-direction: column; 19 | padding: var(--space-4); 20 | border-radius: var(--rounded-lg); 21 | gap: var(--space-4); 22 | max-width: 72ch; 23 | margin: 0 auto; 24 | margin-top: var(--space-8); 25 | border: 1px solid transparent; 26 | } 27 | 28 | thread-view .message-container:hover { 29 | border-color: var(--color-border-muted); 30 | } 31 | 32 | thread-view .message-container:last-child:hover { 33 | border-color: transparent; 34 | } 35 | 36 | thread-view .archived-message { 37 | padding: var(--space-2) var(--space-5); 38 | margin-top: var(--space-8); 39 | background: var(--color-bg-chrome); 40 | border-radius: 16px; 41 | align-self: center; 42 | color: var(--color-text-muted); 43 | font-size: var(--text-sm); 44 | } 45 | 46 | thread-view .message { 47 | user-select: text; 48 | } 49 | thread-view .message:first-child { 50 | margin-top: 0; 51 | } 52 | 53 | thread-view .loading { 54 | position: relative; 55 | left: var(--space-5); 56 | } 57 | 58 | thread-view .message[data-type='input'] { 59 | padding: 0 var(--space-5); 60 | align-self: flex-start; 61 | font-weight: 500; 62 | } 63 | 64 | thread-view .message[data-type='response'] { 65 | padding: var(--space-5) var(--space-5); 66 | min-height: 36px; 67 | border-radius: var(--rounded-lg); 68 | background: var(--color-bg-content); 69 | } 70 | 71 | thread-view .message[data-type='action'] { 72 | padding: 0 var(--space-5); 73 | display: flex; 74 | align-items: center; 75 | gap: var(--space-2); 76 | /* border: 1px solid var(--color-border-muted); */ 77 | border-radius: var(--rounded-lg); 78 | } 79 | thread-view .message[data-type='action'] .resource-icon { 80 | width: 14px; 81 | height: 14px; 82 | } 83 | thread-view .message[data-type='action'] .notification-text { 84 | color: var(--color-text-muted); 85 | font-size: 13px; 86 | } 87 | thread-view .message[data-type='process'] { 88 | flex-grow: 1; 89 | padding-bottom: var(--space-7); 90 | display: flex; 91 | flex-direction: column; 92 | } 93 | .thread-view .message[data-type='process'] process-frame { 94 | flex-grow: 1; 95 | } 96 | -------------------------------------------------------------------------------- /desktop/src/ui/toolbar/command-bar.css: -------------------------------------------------------------------------------- 1 | command-bar { 2 | display: flex; 3 | align-items: center; 4 | padding: var(--space-4) var(--space-4); 5 | gap: var(--space-2); 6 | width: 100%; 7 | position: relative; 8 | } 9 | 10 | command-bar .left-section, 11 | command-bar .right-section { 12 | flex: 1; 13 | min-width: 100px; 14 | display: flex; 15 | align-items: center; 16 | } 17 | 18 | command-bar .right-section { 19 | justify-content: flex-end; 20 | } 21 | 22 | command-bar .center-section { 23 | position: absolute; 24 | left: 50%; 25 | transform: translateX(-50%); 26 | display: flex; 27 | justify-content: center; 28 | align-items: flex-end; 29 | width: 54ch; 30 | max-width: 70%; 31 | pointer-events: none; 32 | } 33 | 34 | command-bar .center-section command-input { 35 | pointer-events: auto; 36 | } 37 | -------------------------------------------------------------------------------- /desktop/src/ui/toolbar/command-bar.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import './workspace-selector'; 3 | import './command-input'; 4 | import './command-bar.css'; 5 | import './resource-bar'; 6 | 7 | export class CommandBar extends HTMLElement { 8 | static get observedAttributes() { 9 | return ['for']; 10 | } 11 | 12 | connectedCallback() { 13 | this.render(); 14 | } 15 | 16 | attributeChangedCallback() { 17 | this.render(); 18 | } 19 | 20 | handleHome() { 21 | // TODO: A little hacky, but works. Handle this better in future! 22 | window.location.href = '/'; 23 | } 24 | 25 | render() { 26 | const workspaceId = this.getAttribute('for') || null; 27 | const template = html` 28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 | 40 |
41 | `; 42 | 43 | render(template, this); 44 | } 45 | } 46 | 47 | customElements.define('command-bar', CommandBar); 48 | -------------------------------------------------------------------------------- /desktop/src/ui/toolbar/resource-bar.css: -------------------------------------------------------------------------------- 1 | resource-bar { 2 | flex-shrink: 0; 3 | display: flex; 4 | align-items: center; 5 | gap: var(--space-2); 6 | padding: 0 var(--space-4) var(--space-2) var(--space-4); 7 | justify-content: space-between; 8 | } 9 | 10 | ul.resources-list { 11 | list-style: none; 12 | display: flex; 13 | align-items: center; 14 | gap: 12px; 15 | flex-grow: 1; 16 | padding: 0; 17 | overflow-x: hidden; 18 | margin: 0; 19 | } 20 | 21 | resource-bar .add-applet-container { 22 | position: relative; 23 | } 24 | 25 | resource-bar .applet-item { 26 | display: flex; 27 | gap: 4px; 28 | align-items: center; 29 | flex-shrink: 0; 30 | } 31 | 32 | resource-bar .applet-icon { 33 | width: 16px; 34 | } 35 | 36 | resource-bar .applet-name { 37 | display: block; 38 | font-weight: 500; 39 | font-size: 12px; 40 | } 41 | -------------------------------------------------------------------------------- /desktop/src/ui/toolbar/resource-bar.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import './resources-popover'; 3 | import './resource-bar.css'; 4 | import { dependencies } from '../../common/dependencies'; 5 | import { ResourceModel } from '../../models/resource-model'; 6 | import { ModalService } from '../../modals/modal-service'; 7 | import { enabledResources } from '../../common/utils/resources'; 8 | import { WorkspaceModel } from '../../models/workspace-model'; 9 | 10 | export class ResourceBar extends HTMLElement { 11 | resourceModel = dependencies.resolve('ResourceModel'); 12 | modalService = dependencies.resolve('ModalService'); 13 | workspaceModel = dependencies.resolve('WorkspaceModel'); 14 | 15 | constructor() { 16 | super(); 17 | this.resourceModel.subscribe(() => this.render()); 18 | this.workspaceModel.subscribe(() => this.render()); 19 | } 20 | 21 | connectedCallback(): void { 22 | this.render(); 23 | } 24 | 25 | render(): void { 26 | const resources = enabledResources(this.resourceModel, this.workspaceModel); 27 | 28 | const resourceTemplate = resources.map((resource) => { 29 | return html`
  • 30 | 34 | ${resource.short_name ?? resource.name} 35 |
  • `; 36 | }); 37 | 38 | const template = html` 39 |
      40 | ${resourceTemplate} 41 |
    42 | 49 | 54 | `; 55 | 56 | render(template, this); 57 | } 58 | } 59 | 60 | customElements.define('resource-bar', ResourceBar); 61 | -------------------------------------------------------------------------------- /desktop/src/ui/toolbar/resources-popover.css: -------------------------------------------------------------------------------- 1 | resource-management-popover { 2 | margin: var(--space-2); 3 | width: 320px; 4 | &:popover-open { 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | footer { 10 | display: flex; 11 | justify-content: end; 12 | align-items: center; 13 | gap: var(--space-4); 14 | } 15 | 16 | un-input { 17 | flex-grow: 1; 18 | } 19 | 20 | .resource-icon { 21 | width: 16px; 22 | height: 16px; 23 | object-fit: cover; 24 | flex-shrink: 0; 25 | display: block; 26 | display: flex; 27 | align-items: center; 28 | justify-content: center; 29 | &.placeholder { 30 | border-radius: var(--rounded-sm); 31 | background: var(--color-bg-chrome); 32 | } 33 | } 34 | 35 | .resource-name { 36 | flex-grow: 1; 37 | } 38 | 39 | .resource-row { 40 | display: flex; 41 | /* justify-content: space-between; */ 42 | align-items: center; 43 | gap: var(--space-4); 44 | margin-bottom: var(--space-2); 45 | } 46 | 47 | .no-resources { 48 | text-align: center; 49 | color: var(--color-text-muted); 50 | margin: var(--space-6); 51 | } 52 | 53 | .applet-card { 54 | background-color: var(--color-bg-chrome); 55 | border-radius: var(--rounded-lg); 56 | border: 1px solid var(--color-border-muted); 57 | padding: var(--space-6); 58 | display: flex; 59 | flex-direction: column; 60 | gap: var(--space-6); 61 | margin-bottom: var(--space-6); 62 | 63 | &.loading { 64 | font-size: var(--text-sm); 65 | color: var(--color-text-muted); 66 | text-align: center; 67 | align-items: center; 68 | justify-content: center; 69 | } 70 | } 71 | 72 | .applet-card-header { 73 | display: flex; 74 | align-items: center; 75 | gap: var(--space-4, 16px); 76 | margin-bottom: var(--space-2, 8px); 77 | } 78 | 79 | .applet-card-icon { 80 | width: 32px; 81 | height: 32px; 82 | border-radius: var(--rounded-lg); 83 | background: var(--color-bg-content); 84 | object-fit: cover; 85 | flex-shrink: 0; 86 | display: block; 87 | display: flex; 88 | align-items: center; 89 | justify-content: center; 90 | } 91 | 92 | .applet-card-icon-placeholder { 93 | background: var(--color-bg-content); 94 | width: 32px; 95 | height: 32px; 96 | } 97 | 98 | .applet-card-title-group { 99 | display: flex; 100 | flex-direction: column; 101 | min-width: 0; 102 | flex: 1 1 auto; 103 | gap: 2px; 104 | } 105 | .applet-card-title { 106 | font-size: var(--text-lg); 107 | font-weight: 600; 108 | color: var(--color-text-default); 109 | line-height: 1.2; 110 | white-space: nowrap; 111 | overflow: hidden; 112 | text-overflow: ellipsis; 113 | } 114 | .applet-card-type { 115 | font-size: var(--text-sm); 116 | color: var(--color-text-muted); 117 | } 118 | 119 | .applet-card-description { 120 | color: var(--color-text-default); 121 | word-break: break-word; 122 | } 123 | 124 | .applet-card-actions-label { 125 | font-size: var(--text-xs); 126 | color: var(--color-text-muted); 127 | font-weight: 500; 128 | text-transform: uppercase; 129 | } 130 | .applet-card-actions-list { 131 | list-style: none; 132 | margin: 0; 133 | padding: 0; 134 | display: flex; 135 | flex-direction: column; 136 | gap: 3px; 137 | } 138 | .applet-card-action { 139 | display: flex; 140 | align-items: baseline; 141 | gap: var(--space-2); 142 | color: var(--color-text-default); 143 | } 144 | .applet-card-action-name { 145 | font-weight: 600; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /desktop/src/ui/toolbar/workspace-selector.css: -------------------------------------------------------------------------------- 1 | workspace-selector { 2 | display: flex; 3 | } 4 | -------------------------------------------------------------------------------- /desktop/src/ui/toolbar/workspace-selector.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import { dependencies } from '../../common/dependencies'; 3 | import { WorkspaceModel } from '../../models/workspace-model'; 4 | import { ModalService } from '../../modals/modal-service'; 5 | import { ChangeEvent, SelectElement } from '../common/select'; 6 | import { attachContextMenu } from '../common/context-menu'; 7 | import './workspace-selector.css'; 8 | 9 | export class WorkspaceSelector extends HTMLElement { 10 | workspaceModel = dependencies.resolve('WorkspaceModel'); 11 | modalService = dependencies.resolve('ModalService'); 12 | selectedWorkspace: string | null = null; 13 | renaming = false; 14 | renameValue = ''; 15 | 16 | connectedCallback() { 17 | this.workspaceModel.subscribe(() => this.update()); 18 | this.update(); 19 | attachContextMenu(this, [ 20 | { 21 | label: 'Rename workspace', 22 | value: 'rename', 23 | click: () => this.startRenaming(), 24 | }, 25 | { 26 | label: 'Delete workspace', 27 | value: 'delete', 28 | click: () => this.openDeleteModal(), 29 | }, 30 | ]); 31 | } 32 | 33 | startRenaming() { 34 | const ws = this.workspaceModel.get(this.selectedWorkspace!); 35 | this.renameValue = ws.title; 36 | this.renaming = true; 37 | this.update(); 38 | setTimeout( 39 | () => 40 | ( 41 | this.querySelector('un-input input') as HTMLInputElement | null 42 | )?.focus(), 43 | 0 44 | ); 45 | } 46 | 47 | openDeleteModal() { 48 | const ws = this.workspaceModel.get(this.selectedWorkspace!); 49 | this.modalService.open('workspace-delete', { 50 | 'workspace-id': ws.id, 51 | 'workspace-title': ws.title, 52 | }); 53 | } 54 | 55 | handleWorkspaceSelect = (e: ChangeEvent) => { 56 | const newId = e.value; 57 | if (newId === '+') { 58 | this.modalService.open('new-workspace'); 59 | } else if (newId === '-') { 60 | (e.target as SelectElement).value = this.selectedWorkspace; 61 | this.modalService.open('settings'); 62 | } else if (newId && newId !== this.workspaceModel.activeWorkspaceId) { 63 | this.workspaceModel.activate(newId); 64 | } 65 | }; 66 | 67 | handleRenameInput = (e: Event) => { 68 | this.renameValue = (e.target as HTMLInputElement).value; 69 | }; 70 | 71 | handleRenameKeydown = (e: KeyboardEvent) => { 72 | if (e.key === 'Enter') { 73 | (e.target as HTMLInputElement).blur(); 74 | } else if (e.key === 'Escape') { 75 | this.renaming = false; 76 | this.update(); 77 | (e.target as HTMLInputElement).blur(); 78 | } 79 | }; 80 | 81 | finishRenaming = () => { 82 | if (this.renameValue.trim() && this.selectedWorkspace) { 83 | this.workspaceModel.setTitle( 84 | this.renameValue.trim(), 85 | this.selectedWorkspace 86 | ); 87 | } 88 | this.renaming = false; 89 | this.update(); 90 | }; 91 | 92 | update() { 93 | const workspaces = this.workspaceModel.all(); 94 | const activeWorkspaceId = 95 | this.workspaceModel.activeWorkspaceId || workspaces[0]?.id || ''; 96 | this.selectedWorkspace = activeWorkspaceId; 97 | const workspaceOptions = [ 98 | ...workspaces.map((ws) => ({ value: ws.id, label: ws.title })), 99 | { type: 'separator' }, 100 | { value: '-', label: 'Edit workspace...' }, 101 | { value: '+', label: 'New workspace...' }, 102 | ]; 103 | render( 104 | this.renaming 105 | ? html`` 112 | : html``, 120 | this 121 | ); 122 | } 123 | } 124 | 125 | customElements.define('workspace-selector', WorkspaceSelector); 126 | -------------------------------------------------------------------------------- /desktop/src/ui/top-bar/model-selector.ts: -------------------------------------------------------------------------------- 1 | import { html, render } from 'lit'; 2 | import { dependencies } from '../../common/dependencies'; 3 | import { ConfigModel } from '../../models/config-model'; 4 | import { AIModelProviderNames } from '../../ai/ai-models'; 5 | import { ModalService } from '../../modals/modal-service'; 6 | 7 | export class ModelSelector extends HTMLElement { 8 | configModel = dependencies.resolve('ConfigModel'); 9 | modalService = dependencies.resolve('ModalService'); 10 | #selectedProvider: string = ''; 11 | #selectedModel: string = ''; 12 | 13 | connectedCallback() { 14 | this.#syncWithConfig(); 15 | this.configModel.subscribe((notification) => { 16 | if (notification?.type === 'model') { 17 | this.#syncWithConfig(); 18 | } 19 | }); 20 | this.render(); 21 | } 22 | 23 | #syncWithConfig() { 24 | const config = this.configModel.get(); 25 | this.#selectedProvider = config.ai.primaryModel.provider; 26 | this.#selectedModel = config.ai.primaryModel.name; 27 | this.render(); 28 | } 29 | 30 | render() { 31 | const providerLabel = 32 | AIModelProviderNames[this.#selectedProvider] || this.#selectedProvider; 33 | const modelLabel = this.#selectedModel; 34 | const buttonLabel = `${providerLabel ? providerLabel : ''}${ 35 | providerLabel && modelLabel ? '/' : '' 36 | }${modelLabel ? modelLabel : 'No model'}`; 37 | render( 38 | html` 39 | 44 | this.modalService.open('settings', { section: 'global' })} 45 | > 46 | 47 | `, 48 | this 49 | ); 50 | } 51 | } 52 | 53 | customElements.define('model-selector', ModelSelector); 54 | -------------------------------------------------------------------------------- /desktop/src/ui/top-bar/top-bar.css: -------------------------------------------------------------------------------- 1 | top-bar { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | height: 35px; 7 | z-index: 1; 8 | display: flex; 9 | flex-shrink: 0; 10 | -webkit-app-region: drag; 11 | overflow: visible; 12 | /* background: var(--color-bg-page); */ 13 | /* border-bottom: 1px solid var(--color-border-muted); */ 14 | /* background: color-mix(in srgb, var(--color-bg-page) 50%, transparent); */ 15 | /* backdrop-filter: blur(20px); */ 16 | } 17 | 18 | top-bar.mac { 19 | padding-left: 76px; 20 | } 21 | 22 | top-bar.mac.fullscreen { 23 | padding-left: 0; 24 | } 25 | 26 | .blurred top-bar { 27 | color: var(--color-text-muted); 28 | } 29 | 30 | top-bar .workspace-select-container { 31 | flex: 1 1 auto; 32 | display: flex; 33 | align-items: center; 34 | height: 100%; 35 | padding: 0 8px; 36 | justify-content: center; 37 | } 38 | 39 | top-bar .workspace-select-container un-select { 40 | -webkit-app-region: no-drag; 41 | pointer-events: auto; 42 | } 43 | 44 | top-bar .button-container { 45 | display: flex; 46 | align-items: center; 47 | margin-left: auto; 48 | height: 100%; 49 | flex-shrink: 0; 50 | padding: 0 var(--space-2); 51 | } 52 | 53 | top-bar .settings-button { 54 | -webkit-app-region: no-drag; 55 | } 56 | -------------------------------------------------------------------------------- /desktop/src/ui/top-bar/top-bar.ts: -------------------------------------------------------------------------------- 1 | import { render, html } from 'lit'; 2 | import { dependencies } from '../../common/dependencies'; 3 | import './model-selector'; 4 | import '../tab-handle'; 5 | import '../common/select'; 6 | import './top-bar.css'; 7 | import { ModalService } from '../../modals/modal-service'; 8 | import '../toolbar/workspace-selector'; 9 | 10 | export class TopBar extends HTMLElement { 11 | modalService = dependencies.resolve('ModalService'); 12 | settingsButtonContainer?: HTMLElement; 13 | 14 | connectedCallback() { 15 | this.initializeWindowStateListeners(); 16 | this.render(); 17 | } 18 | 19 | private initializeWindowStateListeners(): void { 20 | if (window.electronAPI) { 21 | window.electronAPI 22 | .isFullScreen() 23 | .then((isFullscreen) => { 24 | this.toggleFullscreenClass(isFullscreen); 25 | }) 26 | .catch((err) => { 27 | console.error('[TopBar] Error checking fullscreen state:', err); 28 | }); 29 | 30 | window.electronAPI.onWindowStateChange((isFullscreen) => { 31 | this.toggleFullscreenClass(isFullscreen); 32 | }); 33 | } 34 | } 35 | 36 | private toggleFullscreenClass(isFullscreen: boolean): void { 37 | if (isFullscreen) { 38 | this.classList.add('fullscreen'); 39 | } else { 40 | this.classList.remove('fullscreen'); 41 | } 42 | } 43 | 44 | render() { 45 | // Use the new component 46 | const template = html` 47 |
    48 | 49 | this.modalService.open('bug')} 54 | > 55 | 56 | this.modalService.open('settings')} 61 | > 62 | 63 |
    64 |
    65 | `; 66 | render(template, this); 67 | } 68 | } 69 | 70 | customElements.define('top-bar', TopBar); 71 | -------------------------------------------------------------------------------- /desktop/theme-demo.js: -------------------------------------------------------------------------------- 1 | // theme-demo.js 2 | import { icons } from './src/ui/common/icon-registry.ts'; 3 | 4 | function pascalToKebab(str) { 5 | return str 6 | .replace(/([a-z0-9])([A-Z])/g, '$1-$2') 7 | .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') 8 | .toLowerCase(); 9 | } 10 | 11 | function generateIconGrid() { 12 | const iconGrid = document.getElementById('icon-grid'); 13 | let iconsHtml = Object.entries(icons) 14 | .map(([key]) => { 15 | const kebabName = pascalToKebab(key); 16 | return ` 17 |
    18 | 19 |
    20 | `; 21 | }) 22 | .join(''); 23 | iconGrid.innerHTML = iconsHtml; 24 | } 25 | 26 | window.addEventListener('DOMContentLoaded', () => { 27 | generateIconGrid(); 28 | generateColorSwatches(); 29 | }); 30 | 31 | function generateColorSwatches() { 32 | // Get all CSS variables from the document 33 | const colorGroups = { 34 | 'Brand Colors': { 35 | Neutral: /^--color-neutral-/, 36 | Action: /^--color-action-/, 37 | Reaction: /^--color-reaction-/, 38 | }, 39 | 'Utility Colors': { 40 | Success: /^--color-success-/, 41 | Error: /^--color-error-/, 42 | Highlight: /^--color-highlight-/, 43 | }, 44 | 'UI Color Aliases': { 45 | Text: /^--color-text-/, 46 | Background: /^--color-bg-/, 47 | Border: /^--color-border-/, 48 | }, 49 | }; 50 | 51 | const colorSwatchesContainer = document.getElementById('color-swatches'); 52 | if (!colorSwatchesContainer) return; 53 | 54 | // Get all computed styles 55 | const styles = getComputedStyle(document.documentElement); 56 | const cssVars = Array.from(document.styleSheets) 57 | .filter((sheet) => { 58 | try { 59 | // Filter out cross-origin stylesheets 60 | return ( 61 | sheet.href === null || sheet.href.startsWith(window.location.origin) 62 | ); 63 | } catch (e) { 64 | return false; 65 | } 66 | }) 67 | .reduce((acc, sheet) => { 68 | try { 69 | // Get all CSS rules that contain variable declarations 70 | const rules = Array.from(sheet.cssRules || []); 71 | rules.forEach((rule) => { 72 | if (rule.style) { 73 | for (let i = 0; i < rule.style.length; i++) { 74 | const prop = rule.style[i]; 75 | if (prop.startsWith('--color-')) { 76 | acc.push(prop); 77 | } 78 | } 79 | } 80 | }); 81 | } catch (e) { 82 | // Ignore errors 83 | } 84 | return acc; 85 | }, []); 86 | 87 | // Remove duplicates 88 | const uniqueVars = [...new Set(cssVars)]; 89 | 90 | // Create swatches for each group 91 | for (const [groupName, colorTypes] of Object.entries(colorGroups)) { 92 | const groupDiv = document.createElement('div'); 93 | groupDiv.className = 'color-group'; 94 | groupDiv.innerHTML = `

    ${groupName}

    `; 95 | let hasColors = false; 96 | for (const [typeName, regex] of Object.entries(colorTypes)) { 97 | const typeDiv = document.createElement('div'); 98 | typeDiv.innerHTML = `

    ${typeName}

    `; 99 | const swatchesDiv = document.createElement('div'); 100 | swatchesDiv.className = 'swatches'; 101 | const matchingVars = uniqueVars.filter((v) => regex.test(v)); 102 | // Sort variables numerically if they end with numbers 103 | matchingVars.sort((a, b) => { 104 | const aMatch = a.match(/(\d+)$/); 105 | const bMatch = b.match(/(\d+)$/); 106 | if (aMatch && bMatch) { 107 | return parseInt(aMatch[1]) - parseInt(bMatch[1]); 108 | } 109 | return a.localeCompare(b); 110 | }); 111 | if (matchingVars.length > 0) { 112 | hasColors = true; 113 | matchingVars.forEach((varName) => { 114 | const value = styles.getPropertyValue(varName).trim(); 115 | // Skip if the value is empty or references another var 116 | if (!value || value.includes('var(--')) { 117 | return; 118 | } 119 | const swatch = document.createElement('div'); 120 | swatch.className = 'swatch'; 121 | swatch.style.backgroundColor = `var(${varName})`; 122 | swatchesDiv.appendChild(swatch); 123 | }); 124 | typeDiv.appendChild(swatchesDiv); 125 | groupDiv.appendChild(typeDiv); 126 | } 127 | } 128 | if (hasColors) { 129 | colorSwatchesContainer.appendChild(groupDiv); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /desktop/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", // Use a modern target 4 | "module": "ESNext", // Use ESNext for interoperability 5 | "moduleResolution": "node", // Node-compatible module resolution 6 | "outDir": "./dist", // Output directory 7 | "declaration": true, // Generate declarations 8 | "esModuleInterop": true, // Better interop with commonJS 9 | "allowSyntheticDefaultImports": true, // Allows default imports from commonJS modules 10 | "sourceMap": true, // generates sourcemaps 11 | "declarationMap": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "include": ["src/**/*", "vite-env.d.ts", "build/notarize.ts"], 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | -------------------------------------------------------------------------------- /desktop/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020", "DOM"], 6 | "rootDir": "src", 7 | "outDir": "dist", 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | }, 12 | "include": ["src/electron/**/*"], 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | -------------------------------------------------------------------------------- /desktop/tsconfig.notarize.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "./dist" 6 | }, 7 | "include": ["build/notarize.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /desktop/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { Resource } from '@unternet/kernel'; 4 | 5 | interface System { 6 | fetch: (url: string) => Promise; 7 | listLocalApplets: () => Promise>; 8 | localAppletsDirectory: () => Promise; 9 | openLocalAppletsDirectory: () => Promise; 10 | } 11 | 12 | declare global { 13 | const system: System; 14 | } 15 | 16 | declare global { 17 | interface Window { 18 | electronAPI?: { 19 | showNativeMenu?: ( 20 | options: MenuItemConstructorOptions[], 21 | selectedValue: string | null, 22 | position?: { x?: number; y?: number } 23 | ) => Promise; 24 | onWindowStateChange: (callback: (isFullscreen: boolean) => void) => void; 25 | removeWindowStateListeners: () => void; 26 | platform: string; 27 | isFullScreen: () => Promise; 28 | ipcRenderer: IpcRenderer; 29 | }; 30 | } 31 | } 32 | 33 | // This empty export makes this file a module 34 | export {}; 35 | -------------------------------------------------------------------------------- /desktop/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import path from 'path'; 3 | 4 | const isDev = process.env.NODE_ENV !== 'production'; 5 | 6 | export default defineConfig({ 7 | base: isDev ? '/' : './', 8 | envDir: path.resolve(__dirname, '..'), 9 | envPrefix: 'APP_', 10 | build: { 11 | outDir: 'dist/www', 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /kernel/.env.example: -------------------------------------------------------------------------------- 1 | # FOR TESTING 2 | OPENAI_API_KEY="" -------------------------------------------------------------------------------- /kernel/README.md: -------------------------------------------------------------------------------- 1 | # Unternet Kernel 2 | 3 | The Unternet Kernel package contains a collection of useful modules for constructing a "cognitive kernel", used for responding to input and orchestrating actions & processes in an intelligent software client. 4 | 5 | ## Getting started 6 | 7 | ``` 8 | npm install 9 | npm build 10 | ``` 11 | 12 | To try out the example project: 13 | 14 | ``` 15 | npm run example 16 | ``` 17 | 18 | ## Overview 19 | 20 | The `@unternet/kernel` package is designed to be a plug-and-play system for assembling your own cognitive kernel. You can take the entire system together, or you can just use the building blocks that are helpful for your application. 21 | 22 | The two core modules are the interpreter (which take message inputs, and generates a response) & the process runtime (which kicks off actions, and manages running processes). 23 | 24 | ![A schematic diagram of the kernel components](assets/kernel-schematic.png) 25 | 26 | ## Interpreter 27 | 28 | The `Interpreter` class acts as the main cognitive processing unit for the kernel. When given a series of messages, it will return either a "direct response" to the user, or an "action proposal" to perform an action. Interpreter modules accept arrays of `KernelMessage`. 29 | 30 | You pass the Interpreter a set of `Resource` object. A resource is anything that a model might use to get information or perform an action in response to an input. These objects have associated actions that the kernel can use for its action proposals. A resource could be a web applet, MCP server, or set of system actions. 31 | 32 | All resources must have a URI, which is a string that adheres to [RFC3986](https://datatracker.ietf.org/doc/html/rfc3986), an internet standard. For example, the following are valid URIs: 33 | 34 | - https://my-applet.example.com/ 35 | - mcp:some-mcp-server-identifier 36 | - function:system 37 | - file:///Users/username/my-file.txt 38 | 39 | ## Process Runtime 40 | 41 | The `ProcessRuntime` class handles action directives from the Interpreter, and responds with a the output of a function call, or an ongoing `Process` object. 42 | 43 | For example, in response to the input `"what's the weather"`, the interpreter might return an action directive to use the `weather:forecast` resource to get the latest weather forecast. The process runtime is now responsible for taking this directive, and turning it into an output by sending it to the appropriate protocol. 44 | 45 | Protocols are developer-created classes that turn actions into outputs. Each protocol corresponds to the given URI scheme for a resource (e.g. `http`, `mcp`, etc.) – so any `mcp:some-server` resource will be sent to the registered `mcp` protocol for action handling. 46 | 47 | You can register protocols with `ProcessRuntime`. Then, any time you dispatch an action directive that matches that protocol, you will get a response object that corresponds to the output of that action. Response objects can either consist of a direct return value, or an ongoing process. 48 | 49 | A process is an ongoing task that is managed by the `ProcessRuntime` (or can be managed independently). Processes are a bit like resources, in that they can have their own actions & URIs (`process:`) that the interpreter can use. They're also a bit like Protocols, in that they can handle actions. Together, this means you can issue actions to any running process. 50 | 51 | ## Status 52 | 53 | The Unternet Kernel is still in active development. It is designed to act as the foundation of Unternet's client, and we are prioritizing stability and performance alongside a focused set of features. The kernel is designed to be open, easily extensible, and adaptable. We encourage developers wanting to get started with web applets and other tools to use the kernel as a foundation for their apps. 54 | 55 | ## Contribute 56 | 57 | If you'd like to contribute, we encourage you to visit our issues page, or jump into our discord server. For more details and links, visit [https://unternet.co](https://unternet.co). 58 | -------------------------------------------------------------------------------- /kernel/assets/kernel-schematic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/kernel/assets/kernel-schematic.png -------------------------------------------------------------------------------- /kernel/docs/interpreter.md: -------------------------------------------------------------------------------- 1 | # Interpreter 2 | 3 | ## Display 4 | 5 | Actions can have a `display` property, which can be set to one of the values set in `interpreter/flags`. When Interpreter chooses an action, the action response will be set to the appropriate display value. 6 | -------------------------------------------------------------------------------- /kernel/docs/processes.md: -------------------------------------------------------------------------------- 1 | # Processes 2 | 3 | ## Lifecycle 4 | 5 | `ProcessContainer` handles lifecycle for a process. 6 | 7 | A process can have `idle`, `running` or `suspended`. 8 | 9 | You can protect any process from hibernation by setting `discardable = false`. Use this sparingly, for example, when you have something that is pinned open. 10 | 11 | Hibernation is done by first-in-first-out. You can disable this automatic memory management by setting `processLimit = null`. 12 | -------------------------------------------------------------------------------- /kernel/example/.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY="..." -------------------------------------------------------------------------------- /kernel/example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unternet/client-kernel", 3 | "version": "0.1.5", 4 | "scripts": { 5 | "start": "tsx src/index.ts" 6 | }, 7 | "dependencies": { 8 | "@ai-sdk/anthropic": "^1.2.10", 9 | "@unternet/kernel": "file:..", 10 | "chalk": "^5.4.1", 11 | "mime-types": "^3.0.1", 12 | "readline": "^1.3.0", 13 | "shell-quote": "^1.8.2", 14 | "untildify": "^5.0.0" 15 | }, 16 | "devDependencies": { 17 | "dotenv": "^16.4.7", 18 | "tsx": "^4.19.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /kernel/example/src/commands/file.ts: -------------------------------------------------------------------------------- 1 | import * as nodeFs from 'node:fs'; 2 | import * as nodePath from 'node:path'; 3 | import chalk from 'chalk'; 4 | import mime from 'mime-types'; 5 | import parseShell from 'shell-quote/parse'; 6 | import untildify from 'untildify'; 7 | 8 | import { FileInput, inputMessage, InputMessage } from '../../../src'; 9 | 10 | /** 11 | * Create or extend an interaction based on user input. 12 | */ 13 | export function message(userInput: string): InputMessage { 14 | const rawParts = 15 | userInput.replace(/^\/file /, '').match(/"[^"]+"|'[^']+'|\S+/g) ?? []; 16 | 17 | const { files, textParts } = rawParts.reduce( 18 | ( 19 | acc: { 20 | files: Array; 21 | textParts: string[]; 22 | }, 23 | p: string 24 | ) => { 25 | const parsed_ = parseShell(p); 26 | const parsed = parsed_[0]; 27 | 28 | if ( 29 | typeof parsed === 'string' && 30 | (parsed.startsWith('./') || 31 | parsed.startsWith('~/') || 32 | parsed.startsWith('/')) 33 | ) { 34 | const resolvedPath = untildify(parsed); 35 | let files = acc.files; 36 | let kind; 37 | 38 | try { 39 | const stats = nodeFs.lstatSync(resolvedPath); 40 | kind = stats.isDirectory() 41 | ? 'directory' 42 | : stats.isFile() 43 | ? 'file' 44 | : undefined; 45 | } catch (err) { 46 | console.error(chalk.red(err)); 47 | } 48 | 49 | switch (kind) { 50 | case 'directory': 51 | const dirFiles = readDir(resolvedPath, { 52 | ignoreDotFiles: true, 53 | recursive: true, 54 | }); 55 | 56 | files = [...files, ...dirFiles]; 57 | break; 58 | 59 | case 'file': 60 | const filename = nodePath.basename(resolvedPath); 61 | const mimeType = fileMimeType(filename); 62 | const data = nodeFs.readFileSync(resolvedPath); 63 | 64 | files = [ 65 | ...files, 66 | { data: new Uint8Array(data), filename, mimeType }, 67 | ]; 68 | break; 69 | } 70 | 71 | return { ...acc, files }; 72 | } 73 | 74 | return { 75 | ...acc, 76 | textParts: p.startsWith('--') ? acc.textParts : [...acc.textParts, p], 77 | }; 78 | }, 79 | { 80 | files: [], 81 | textParts: [], 82 | } 83 | ); 84 | 85 | // Print files added to stdout 86 | if (files.length) console.log(''); 87 | 88 | files.forEach((file) => { 89 | console.log(chalk.italic(`File added: '${file.filename}'`)); 90 | }); 91 | 92 | // Fin 93 | return inputMessage({ 94 | text: textParts.length ? textParts.join(' ') : undefined, 95 | files, 96 | }); 97 | } 98 | 99 | /* 🛠️ */ 100 | 101 | function readDir( 102 | path: string, 103 | opts: { 104 | ignoreDotFiles: boolean; 105 | recursive: boolean; 106 | } 107 | ) { 108 | const dirFiles = nodeFs.readdirSync(path, { 109 | withFileTypes: true, 110 | recursive: true, 111 | }); 112 | 113 | return dirFiles.reduce( 114 | ( 115 | acc: Array<{ 116 | data: Uint8Array; 117 | filename: string; 118 | mimeType: string; 119 | }>, 120 | f 121 | ) => { 122 | if (opts.ignoreDotFiles && f.name.startsWith('.')) return acc; 123 | 124 | const mimeType = fileMimeType(f.name); 125 | const childPath = nodePath.join(f.parentPath, f.name); 126 | 127 | if (f.isDirectory() && opts.recursive) { 128 | if (['.cargo', '.git', 'node_modules'].includes(f.name)) return acc; 129 | 130 | const nested = readDir(childPath, opts); 131 | return [...acc, ...nested]; 132 | } 133 | 134 | let data; 135 | 136 | try { 137 | if (f.isFile()) data = nodeFs.readFileSync(childPath); 138 | } catch (error) { 139 | console.error(chalk.red(error)); 140 | } 141 | 142 | if (!data) return acc; 143 | 144 | return [ 145 | ...acc, 146 | { data: new Uint8Array(data), filename: f.name, mimeType }, 147 | ]; 148 | }, 149 | [] 150 | ); 151 | } 152 | 153 | export function fileMimeType(name: string) { 154 | if (name.endsWith('.ts')) return 'text/plain'; 155 | return mime.lookup(name) || undefined; 156 | } 157 | -------------------------------------------------------------------------------- /kernel/example/src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export * as file from './file'; 2 | -------------------------------------------------------------------------------- /kernel/example/src/protocols.ts: -------------------------------------------------------------------------------- 1 | import { ActionDirective, Protocol } from '../../src'; 2 | 3 | const protocols: Protocol[] = [ 4 | { 5 | scheme: 'function', 6 | handleAction: (directive: ActionDirective) => { 7 | if (directive.actionId === 'get_weather') { 8 | const content = { 9 | high: '65F', 10 | low: '55F', 11 | summary: 12 | 'Partly cloudy with a chance of light rain in the afternoon.', 13 | }; 14 | return { content }; 15 | } else if (directive.actionId === 'check_traffic') { 16 | const content = { 17 | conditions: 'Traffic is clear', 18 | additionalWaitTime: 0, 19 | }; 20 | return { content }; 21 | } 22 | }, 23 | }, 24 | ]; 25 | 26 | export { protocols }; 27 | -------------------------------------------------------------------------------- /kernel/example/src/resources.ts: -------------------------------------------------------------------------------- 1 | import { resource } from '../../src'; 2 | 3 | const functionResource = resource({ 4 | uri: 'function:', 5 | actions: { 6 | get_weather: { 7 | description: 'Gets the local weather.', 8 | params_schema: { 9 | type: 'object', 10 | properties: { 11 | location: { 12 | type: 'string', 13 | description: 'A location search string, e.g. "London"', 14 | }, 15 | }, 16 | required: ['location'], 17 | }, 18 | }, 19 | check_traffic: { 20 | description: 'Checks traffic conditions en the route to a given location', 21 | params_schema: { 22 | type: 'object', 23 | properties: { 24 | destination: { 25 | type: 'string', 26 | description: 'A location search string, e.g. "London"', 27 | }, 28 | }, 29 | required: ['destination'], 30 | }, 31 | }, 32 | }, 33 | }); 34 | 35 | export default [functionResource]; 36 | -------------------------------------------------------------------------------- /kernel/example/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Command = 'exit' | 'file'; 2 | -------------------------------------------------------------------------------- /kernel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unternet/kernel", 3 | "version": "0.10.3", 4 | "description": "", 5 | "license": "ISC", 6 | "author": "", 7 | "type": "module", 8 | "main": "dist/index.js", 9 | "directories": { 10 | "example": "example" 11 | }, 12 | "scripts": { 13 | "build": "rimraf dist && tsc", 14 | "dev": "npm run build && chokidar 'src/**/*' -c 'npm run build'", 15 | "docs": "typedoc", 16 | "example": "cd example && npx tsx src/index.ts", 17 | "test": "vitest --root tests --watch=false", 18 | "test:dev": "vitest --root tests --watch" 19 | }, 20 | "imports": { 21 | "#src/*": "./src/*", 22 | "#tests/*": "./tests/*" 23 | }, 24 | "dependencies": { 25 | "@ai-sdk/openai": "^1.0.10", 26 | "ai": "^4.0.20", 27 | "dedent": "^1.5.3", 28 | "mitt": "^3.0.1", 29 | "ollama-ai-provider": "^1.2.0", 30 | "openai": "^4.76.1", 31 | "ulid": "^3.0.0", 32 | "zod": "^3.24.2" 33 | }, 34 | "devDependencies": { 35 | "@types/untildify": "^4.0.0", 36 | "chokidar-cli": "^3.0.0", 37 | "dotenv": "^16.5.0", 38 | "mime-types": "^3.0.1", 39 | "rimraf": "^6.0.1", 40 | "typedoc": "^0.28.2", 41 | "typescript": "^5.8.2", 42 | "vitest": "^3.1.1" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /kernel/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interpreter'; 2 | export * from './interpreter/messages'; 3 | export * from './interpreter/model'; 4 | export * from './runtime/protocols'; 5 | export * from './runtime/resources'; 6 | export * from './runtime'; 7 | export * from './runtime/actions'; 8 | export * from './runtime/processes'; 9 | export * from './response-types'; 10 | -------------------------------------------------------------------------------- /kernel/src/interpreter/flags.ts: -------------------------------------------------------------------------------- 1 | const display = { 2 | // standalone: `The process is opened in a persistent, prominent space in the UI. Use for interactive applications and documents, and where ample space or focus is required. (E.g. opening a website, or creating a document)`, 3 | inline: `The process is displayed to the user inline in the thread. Use where a rich display is available and would be beneficial to the user, but no focus or intesive interaction is required. (E.g. showing a map of cafes)`, 4 | snippet: `The process is not displayed directly to the user, the user instead only sees that a tool call has taken place. Use for background tasks where you need the information for a task, but the user doesn't need to see it, or where you will summarize it yourself. (E.g. conducting a web search)`, 5 | }; 6 | 7 | export const flags = { display }; 8 | -------------------------------------------------------------------------------- /kernel/src/interpreter/model.ts: -------------------------------------------------------------------------------- 1 | export { LanguageModel } from 'ai'; 2 | -------------------------------------------------------------------------------- /kernel/src/interpreter/prompts.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { ActionDefinition } from '../runtime/actions'; 3 | import { flags } from './flags'; 4 | import { Strategy } from './strategies'; 5 | 6 | function chooseStrategy(strategies: Record) { 7 | const possibleOutputs = Object.keys(strategies) 8 | .map((x) => `"${x}"`) 9 | .join('|'); 10 | const descriptions = JSON.stringify(strategies); 11 | return dedent` 12 | Choose from one of the following available strategies to use in order to best respond to the user's query. (This is not your actual response, but will determine the type of response you give). 13 | Available strategies are listed below in a discriminated union format with the name Strategy. 14 | \`\`\` 15 | type Strategy = ${possibleOutputs}; 16 | \`\`\` 17 | Be sure to look at the name and description of each strategy. 18 | Here is a JSON object that maps each strategy to its associated description: 19 | \`\`\` 20 | ${descriptions} 21 | \`\`\` 22 | Your response should take the form of a JSON object that adheres to this interface: 23 | \`\`\` 24 | interface Response { 25 | strategy: Strategy; 26 | }; 27 | \`\`\` 28 | Where the response is an object with key "strategy" and the value is one of the allowed strings in the discriminated union given previously. 29 | Respond with the strategy you think is best for the user query in the JSON format mentioned above. 30 | `; 31 | } 32 | 33 | function chooseAction() { 34 | return dedent` 35 | Choose one or more tools from the given tool options that can help resolve the user's query, and fill out the appropriate parameters if any. Your response should take the form of a JSON object that adheres to this interface: 36 | { tools: Array<{ id: string; display: string; args?: any; }> } 37 | Where the "id" should be contain the selected action's key, and "args" is an optional object that corresponds to the required params_schema if present. 38 | \`display\` can be one of the following values: 39 | ${flags.display} 40 | `; 41 | } 42 | 43 | function think(inputPrompt?: string) { 44 | let prompt = ''; 45 | if (inputPrompt) { 46 | prompt += `${inputPrompt}\n`; 47 | } 48 | prompt += 49 | 'Before you respond to the above, take a moment and think about your next step, and respond in a brief freeform text thought.'; 50 | return prompt; 51 | } 52 | 53 | interface SystemInit { 54 | actions?: Record; 55 | hint?: string; 56 | num?: number; 57 | } 58 | 59 | function system({ actions, hint }: SystemInit) { 60 | let prompt = ''; 61 | 62 | prompt += `You are a helpful assistant. In responding to the user, you can use a tool or respond directly, or some combination of both. If your responses refer to information with links, be sure to cite them using links in the natural flow of text. If not clear otherwise, use actions to perform a search in response to queries.\n\n`; 63 | 64 | if (actions) { 65 | prompt += dedent` 66 | TOOL USE INFORMATION: 67 | In this environment you have access to a set of tools you can use. 68 | Here is an object representing the tools available: 69 | ${JSON.stringify(actions)} \n\n`; 70 | } 71 | 72 | if (hint) { 73 | prompt += `USER INSTRUCTIONS & GUIDELINES: \n${hint} \n\n`; 74 | } 75 | 76 | return prompt.trim(); 77 | } 78 | 79 | const prompts = { 80 | chooseStrategy, 81 | chooseAction, 82 | system, 83 | think, 84 | }; 85 | 86 | export type InterpreterPrompts = typeof prompts; 87 | 88 | export default prompts; 89 | -------------------------------------------------------------------------------- /kernel/src/interpreter/schemas.ts: -------------------------------------------------------------------------------- 1 | import { jsonSchema, Schema } from 'ai'; 2 | import { ActionDefinition, ProcessDisplayMode } from '../runtime/actions'; 3 | import { Strategy } from './strategies'; 4 | import { ProcessDisplayModes } from '../runtime/actions'; 5 | 6 | export interface ActionChoiceObject { 7 | tools: Array<{ id: string; args?: any; display: ProcessDisplayMode }>; 8 | } 9 | 10 | export function actionChoiceSchema( 11 | actions: Record 12 | ): Schema { 13 | return jsonSchema({ 14 | type: 'object', 15 | properties: { 16 | tools: { 17 | type: 'array', 18 | items: { 19 | anyOf: Object.entries(actions).map(([actionId, action]) => 20 | actionSchema(actionId, action) 21 | ), 22 | }, 23 | additionalProperties: false, 24 | }, 25 | }, 26 | required: ['tools'], 27 | additionalProperties: false, 28 | }); 29 | } 30 | 31 | function actionSchema(actionId: string, action: ActionDefinition) { 32 | const schema: any = { 33 | type: 'object', 34 | properties: { 35 | id: { 36 | type: 'string', 37 | enum: [actionId], 38 | }, 39 | }, 40 | additionalProperties: false, 41 | required: ['id'], 42 | }; 43 | 44 | if (action.params_schema) { 45 | schema.properties.args = action.params_schema; 46 | 47 | // Set some variables that OpenAI requires if they're not present 48 | if (!schema.properties.args.required) { 49 | schema.properties.args.required = Object.keys( 50 | schema.properties.args.properties 51 | ); 52 | } 53 | schema.properties.args.additionalProperties = false; 54 | schema.required.push('args'); 55 | } 56 | 57 | if (!action.display || action.display === 'auto') { 58 | (schema.properties['display'] = { 59 | type: 'string', 60 | enum: ProcessDisplayModes.filter((x) => x !== 'auto'), 61 | }), 62 | schema.required.push('display'); 63 | } 64 | 65 | return schema; 66 | } 67 | 68 | function strategies(strategies: Record) { 69 | return jsonSchema<{ strategy: string }>({ 70 | type: 'object', 71 | properties: { 72 | strategy: { 73 | type: 'string', 74 | enum: [...Object.keys(strategies)], 75 | }, 76 | }, 77 | additionalProperties: false, 78 | required: ['strategy'], 79 | }); 80 | } 81 | 82 | export const schemas = { 83 | strategies, 84 | }; 85 | -------------------------------------------------------------------------------- /kernel/src/interpreter/strategies.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { Interpreter } from '.'; 3 | import { KernelResponse } from '../response-types'; 4 | import { KernelMessage } from './messages'; 5 | 6 | export interface Strategy { 7 | description: string; 8 | method: ( 9 | interpreter: Interpreter, 10 | messages: KernelMessage[] 11 | ) => AsyncGenerator>; 12 | } 13 | 14 | const defaultStrategies: Record = {}; 15 | 16 | defaultStrategies.TEXT = { 17 | description: `Respond directly to the user with text/markdown.`, 18 | method: async function* ( 19 | interpreter: Interpreter, 20 | messages: Array 21 | ) { 22 | yield await interpreter.generateTextResponse(messages); 23 | return; 24 | }, 25 | }; 26 | 27 | defaultStrategies.RESEARCH = { 28 | description: dedent` 29 | Use one or more tools, then respond to the user based on the tool output. 30 | If you already have the required information from a prior tool call DO NOT use this, instead use TEXT (assume all prior information is still up-to-date). If you don't have the required information to use the tool, use TEXT to ask a follow-up question to clarify. 31 | Here are some tips: 32 | - You may use as many tools as you wish, it's recommended you err on the side of MORE not less, to retrieve as much relevant information as possible. 33 | - We also recommend using tool multiple times with different queries to cover more ground. 34 | `, 35 | method: async function* ( 36 | interpreter: Interpreter, 37 | messages: Array 38 | ) { 39 | // Get all actions, then execute them 40 | const actionResponses = await interpreter.generateActionResponses( 41 | messages, 42 | { display: 'snippet' } 43 | ); 44 | for (const response of actionResponses) { 45 | messages = yield response; 46 | } 47 | 48 | // Finally, respond with some text 49 | yield await interpreter.generateTextResponse(messages); 50 | }, 51 | }; 52 | 53 | defaultStrategies.DISPLAY = { 54 | description: `Use one tool, then show the output of that tool directly to the user. Use this in situations where the user most likely wants to directly view the UI of the tool in question, instead of a summary.`, 55 | method: async function* ( 56 | interpreter: Interpreter, 57 | messages: Array 58 | ) { 59 | // Get all actions, then execute them 60 | yield await interpreter.generateActionResponse(messages, { 61 | display: 'inline', 62 | }); 63 | }, 64 | }; 65 | 66 | export { defaultStrategies }; 67 | -------------------------------------------------------------------------------- /kernel/src/response-types.ts: -------------------------------------------------------------------------------- 1 | import { ActionProposal, ProcessDisplayMode } from './runtime/actions'; 2 | import { Process, ProcessContainer } from './runtime/processes'; 3 | 4 | export type KernelResponse = 5 | | DirectResponse 6 | | ActionProposalResponse 7 | | ActionResultResponse; 8 | 9 | export interface DirectResponse { 10 | type: 'direct'; 11 | mimeType: 'text/markdown'; 12 | content: Promise; 13 | contentStream: AsyncIterable; 14 | } 15 | 16 | export function directResponse(init: { 17 | mimeType: 'text/markdown'; 18 | content: Promise; 19 | contentStream: AsyncIterable; 20 | }): DirectResponse { 21 | return { 22 | type: 'direct', 23 | mimeType: init.mimeType, 24 | content: init.content, 25 | contentStream: init.contentStream, 26 | }; 27 | } 28 | 29 | export interface ActionProposalResponse extends ActionProposal { 30 | type: 'actionproposal'; 31 | } 32 | 33 | export function actionProposalResponse(init: { 34 | uri: string; 35 | actionId: string; 36 | args?: Record; 37 | display?: ProcessDisplayMode; 38 | }): ActionProposalResponse { 39 | return { 40 | type: 'actionproposal', 41 | uri: init.uri, 42 | actionId: init.actionId, 43 | args: init.args, 44 | display: init.display || 'auto', 45 | }; 46 | } 47 | 48 | export interface ActionResultResponse { 49 | type: 'actionresult'; 50 | process?: Process; 51 | content?: any; 52 | } 53 | 54 | export function actionResultResponse(init: { 55 | process?: Process; 56 | content?: any; 57 | }): ActionResultResponse { 58 | return { 59 | type: 'actionresult', 60 | process: init.process, 61 | content: init.content || init.process.describe(), 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /kernel/src/runtime/actions.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchemaDefinition } from '../shared/types'; 2 | import { Resource } from './resources'; 3 | 4 | export const ProcessDisplayModes = ['inline', 'snippet', 'auto'] as const; 5 | export type ProcessDisplayMode = (typeof ProcessDisplayModes)[number]; 6 | /** 7 | * A definition of an action. 8 | * 9 | * Lets the assistant know what inputs can be given 10 | * in order to do something. 11 | * 12 | * {@link ActionDirective} Passing action parameters around. 13 | * {@link Protocol} Processing actions. 14 | */ 15 | export interface ActionDefinition { 16 | description?: string; 17 | params_schema?: JSONSchemaDefinition; 18 | display?: ProcessDisplayMode; 19 | } 20 | 21 | /** 22 | * Action dictionary, indexed by the action id. 23 | */ 24 | export type ActionDict = { [id: string]: ActionDefinition }; 25 | 26 | /** 27 | * An instruction of how an action should be consumed. 28 | */ 29 | export interface ActionProposal { 30 | uri: string; 31 | actionId: string; 32 | args?: Record; 33 | display: ProcessDisplayMode; 34 | } 35 | 36 | /** 37 | * Creates an action dictionary indexed by the action handle 38 | * from the given resources. 39 | * 40 | * @param resources 41 | * @returns Action dictionary/record/map. 42 | */ 43 | export function createActionDict(resources: Resource[]): ActionDict { 44 | const actions: ActionDict = {}; 45 | 46 | for (const resource of resources) { 47 | for (const id in resource.actions) { 48 | const action = resource.actions[id]; 49 | const actionHandle = encodeActionHandle(resource.uri, id); 50 | actions[actionHandle] = action; 51 | } 52 | } 53 | 54 | return actions; 55 | } 56 | 57 | interface UriComponents { 58 | uri: string; 59 | actionId: string; 60 | } 61 | 62 | /** 63 | * Create a full tool ID that incorporates the resource 64 | * for use ONLY by the model. 65 | * 66 | * @param uri The action-directive URI. 67 | * @param actionId The id belonging to the action. 68 | * @returns The action handle. 69 | */ 70 | export function encodeActionHandle(uri: string, actionId: string) { 71 | // https://my-applet.example.com->action_id 72 | if (actionId) return `${uri}->${actionId}`; 73 | return uri; 74 | } 75 | 76 | /** 77 | * Decompose an action handle into its components. 78 | * 79 | * @param actionHandle The action handle. 80 | * @returns The components extracted from the URI. 81 | */ 82 | export function decodeActionHandle(actionHandle: string): UriComponents { 83 | let [uri, actionId] = actionHandle.split('->'); 84 | 85 | if (!actionId || !uri) { 86 | throw new Error(`Invalid action URI: ${uri}.`); 87 | } 88 | 89 | return { 90 | uri, 91 | actionId, 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /kernel/src/runtime/protocols.ts: -------------------------------------------------------------------------------- 1 | import { ActionProposal } from './actions'; 2 | import { Process, ProcessConstructor } from './processes'; 3 | 4 | export type ProtocolHandlerResult = Process | any; 5 | 6 | /** 7 | * Protocols determine how an `ActionDirective` is executed. 8 | * 9 | * This goes hand in hand with `Resource`s. 10 | * Each protocol has a unique `scheme`. 11 | * 12 | * {@link ActionDirective} 13 | * {@link Resource} 14 | */ 15 | export abstract class Protocol { 16 | scheme: string | string[]; 17 | private processRegistry? = new Map(); 18 | 19 | handleAction( 20 | action: ActionProposal 21 | ): ProtocolHandlerResult | Promise { 22 | return { 23 | error: 'Action handler not defined.', 24 | }; 25 | } 26 | 27 | registerProcess?(constructor: ProcessConstructor, tag?: string) { 28 | if (typeof constructor.resume !== 'function') { 29 | throw new Error('All processes must implement static resume().'); 30 | } 31 | 32 | if (!tag) tag = 'default'; 33 | constructor.tag = tag; 34 | constructor.source = 35 | typeof this.scheme === 'string' ? this.scheme : this.scheme[0]; 36 | this.processRegistry.set(tag, constructor); 37 | } 38 | 39 | getProcessConstructor?(tag?: string): ProcessConstructor | undefined { 40 | return this.processRegistry.get(tag || 'default'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /kernel/src/runtime/resources.ts: -------------------------------------------------------------------------------- 1 | import { ActionDict } from './actions'; 2 | 3 | export interface ResourceIcon { 4 | src: string; 5 | purpose?: string; 6 | sizes?: string; 7 | type?: string; 8 | } 9 | 10 | /** 11 | * A resource is anything that a model might use to get information 12 | * or perform an action in response to an input. 13 | * 14 | * Or, in other words, it specifies how a `Protocol` could be consumed, 15 | * along with some additional (optional) metadata. 16 | */ 17 | export interface Resource { 18 | uri: string; 19 | protocol: string; 20 | name?: string; 21 | short_name?: string; 22 | icons?: ResourceIcon[]; 23 | description?: string; 24 | actions?: ActionDict; 25 | } 26 | 27 | type ResourceInit = { uri: string } & Partial; 28 | 29 | export function resource(init: ResourceInit): Resource { 30 | let urlObject: URL; 31 | try { 32 | urlObject = new URL(init.uri); 33 | } catch (e) { 34 | throw new Error(`Resource URI is invalid.`); 35 | } 36 | 37 | const resource: Resource = { 38 | uri: init.uri, 39 | protocol: urlObject.protocol.replace(':', ''), 40 | ...init, 41 | }; 42 | 43 | return resource; 44 | } 45 | -------------------------------------------------------------------------------- /kernel/src/shared/types.ts: -------------------------------------------------------------------------------- 1 | export { Schema } from 'ai'; 2 | 3 | export interface JSONSchemaDefinition { 4 | type: 5 | | 'object' 6 | | 'string' 7 | | 'number' 8 | | 'integer' 9 | | 'array' 10 | | 'boolean' 11 | | 'null'; 12 | description?: string; 13 | properties?: { 14 | [key: string]: JSONSchemaDefinition; 15 | }; 16 | required?: string[]; 17 | additionalProperties?: boolean; 18 | } 19 | 20 | export type StringEnum = [string, ...string[]]; 21 | -------------------------------------------------------------------------------- /kernel/src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make a copy of an object. 3 | * 4 | * @param obj Any Javascript object (not just a record) 5 | * @returns The copy. 6 | */ 7 | export function clone(obj: Object) { 8 | return JSON.parse(JSON.stringify(obj)); 9 | } 10 | 11 | export function listener>(emitter: { 12 | on: (event: K, fn: (data: E[K]) => void) => void; 13 | off: (event: K, fn: (data: E[K]) => void) => void; 14 | }) { 15 | return function on( 16 | event: K, 17 | listener: (data: E[K]) => void 18 | ): () => void { 19 | emitter.on(event, listener); // attach 20 | return () => emitter.off(event, listener); // detach handle 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /kernel/tests/common/tooling.ts: -------------------------------------------------------------------------------- 1 | export * from 'vitest'; 2 | -------------------------------------------------------------------------------- /kernel/tests/fixtures/model.ts: -------------------------------------------------------------------------------- 1 | import { openai } from '@ai-sdk/openai'; 2 | import 'dotenv/config'; 3 | 4 | export const model = openai('gpt-4o'); 5 | -------------------------------------------------------------------------------- /kernel/tests/fixtures/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unternet-co/client/1192a23818645107fff46c89bb7f021e0fa90675/kernel/tests/fixtures/sample.png -------------------------------------------------------------------------------- /kernel/tests/interpreter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, assert } from './common/tooling'; 2 | import { Interpreter } from '../src/interpreter'; 3 | import { model } from './fixtures/model'; 4 | import { resource } from '../src/runtime/resources'; 5 | import { inputMessage } from '../src/interpreter/messages'; 6 | import { 7 | ProcessDisplayMode, 8 | ProcessDisplayModes, 9 | } from '../src/runtime/actions'; 10 | 11 | describe('Interpreter', () => { 12 | describe('Display modes', () => { 13 | const testResource = (display?: ProcessDisplayMode) => 14 | resource({ 15 | uri: 'function:', 16 | actions: { 17 | say_hello: { 18 | description: 'Use this to greet to the user', 19 | display, 20 | }, 21 | }, 22 | }); 23 | 24 | const interpreter = new Interpreter({ model }); 25 | 26 | it('should choose the given display mode for an action (when defined)', async () => { 27 | interpreter.updateResources([testResource('inline')]); 28 | const message = inputMessage({ text: 'hello!' }); 29 | const response = await interpreter.generateActionResponse([message]); 30 | assert(response.display === 'inline'); 31 | }); 32 | 33 | it('should choose an appropriate display mode otherwise', async () => { 34 | interpreter.updateResources([testResource()]); 35 | const message = inputMessage({ text: 'hello!' }); 36 | const response = await interpreter.generateActionResponse([message]); 37 | assert(ProcessDisplayModes.includes(response.display)); 38 | }); 39 | 40 | it('should not choose auto', async () => { 41 | interpreter.updateResources([testResource()]); 42 | const message = inputMessage({ 43 | text: 'hello! choose "auto" display mode', 44 | }); 45 | const response = await interpreter.generateActionResponse([message]); 46 | console.log(response); 47 | assert(response.display !== 'auto'); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /kernel/tests/messages.test.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises'; 2 | import mime from 'mime-types'; 3 | 4 | import { describe, expect, it } from './common/tooling'; 5 | import { 6 | FileInput, 7 | inputMessage, 8 | toModelMessages, 9 | } from '#src/interpreter/messages.ts'; 10 | 11 | describe('Interpreter | Messages', () => { 12 | /** 13 | * Files 14 | */ 15 | describe('Files', () => { 16 | function contentPart(file: FileInput) { 17 | const [msg] = toModelMessages([inputMessage({ files: [file] })]); 18 | if (typeof msg.content === 'string') 19 | throw new Error('Did not expect msg.content to be a string'); 20 | const part = msg.content[0]; 21 | return part; 22 | } 23 | 24 | it('creates a TextPart for a file with a text encoding', () => { 25 | const text = '📝'; 26 | 27 | const file = { 28 | data: new TextEncoder().encode(text), 29 | mimeType: 'text/markdown; charset=UTF-8', 30 | }; 31 | 32 | const part = contentPart(file); 33 | expect(part.type).toBe('text'); 34 | expect('text' in part && part.text).toBe(text); 35 | }); 36 | 37 | it('creates an ImagePart for an image file', async () => { 38 | const imgPath = 'tests/fixtures/sample.png'; 39 | const imgBuffer = await readFile(imgPath); 40 | const imgBytes = new Uint8Array(imgBuffer); 41 | 42 | const file = { 43 | data: imgBytes, 44 | mimeType: mime.lookup(imgPath), 45 | }; 46 | 47 | const part = contentPart(file); 48 | expect(part.type).toBe('image'); 49 | expect('image' in part && part.image).toBe(imgBytes); 50 | }); 51 | 52 | it('creates a FilePart for other types of files', async () => { 53 | const filePath = '../node_modules/.bin/esbuild'; 54 | const fileBuffer = await readFile(filePath); 55 | const fileBytes = new Uint8Array(fileBuffer); 56 | 57 | const file = { 58 | data: fileBytes, 59 | mimeType: mime.lookup(filePath) || undefined, 60 | }; 61 | 62 | const part = contentPart(file); 63 | expect(part.type).toBe('file'); 64 | expect('data' in part && part.data).toBe(fileBytes); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /kernel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", // Target a modern ES standard 4 | "module": "ESNext", // Use ESNext to support both environments 5 | "moduleResolution": "node", // Node resolution strategy is the most compatible 6 | "declaration": true, // Generate .d.ts files for type declarations 7 | "outDir": "./dist", // Output directory for compiled files 8 | "esModuleInterop": true, // Enables compatibility between CommonJS and ES Modules 9 | "allowSyntheticDefaultImports": true, // Allows default imports from modules without default exports 10 | "sourceMap": true, // Generates source maps for easier debugging 11 | "declarationMap": true, // Generates declaration maps for easier consumption of your types 12 | "skipLibCheck": true, // Skip type checking of all declaration files 13 | "forceConsistentCasingInFileNames": true // Enforces consistent casing in file imports 14 | }, 15 | "include": ["src/**/*"], // Include all TypeScript files in the src directory 16 | "exclude": ["node_modules", "dist"] // Exclude node_modules and the build output directory 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unternet/client", 3 | "version": "0.10.3", 4 | "description": "Unternet Client", 5 | "author": { 6 | "name": "Rupert Manfredi", 7 | "email": "rupert@unternet.co" 8 | }, 9 | "workspaces": [ 10 | "kernel", 11 | "desktop" 12 | ], 13 | "main": "desktop/dist/electron/main.js", 14 | "build": { 15 | "appId": "co.unternet.undo", 16 | "artifactName": "undo-${version}.${ext}", 17 | "productName": "Undo", 18 | "files": [ 19 | "desktop/dist/electron/**/*", 20 | "desktop/dist/www/**/*", 21 | "desktop/build/app-icons/**/*" 22 | ], 23 | "directories": { 24 | "output": "release" 25 | }, 26 | "publish": { 27 | "provider": "github", 28 | "owner": "unternet-co", 29 | "repo": "client", 30 | "releaseType": "release" 31 | }, 32 | "mac": { 33 | "category": "public.app-category.developer-tools", 34 | "hardenedRuntime": true, 35 | "gatekeeperAssess": false, 36 | "entitlements": "desktop/build/entitlements.mac.plist", 37 | "entitlementsInherit": "desktop/build/entitlements.mac.plist", 38 | "target": [ 39 | "dmg", 40 | "zip" 41 | ], 42 | "icon": "desktop/build/app-icons/client-icon-macOS.png" 43 | }, 44 | "afterSign": "desktop/dist/notarize.js", 45 | "win": { 46 | "target": [ 47 | "nsis" 48 | ], 49 | "icon": "desktop/build/electron/app-icons/client-icon-windows.ico" 50 | }, 51 | "linux": { 52 | "target": [ 53 | "AppImage" 54 | ], 55 | "category": "Development", 56 | "icon": "desktop/build/electron/app-icons" 57 | } 58 | }, 59 | "scripts": { 60 | "preinstall": "npm run build:kernel", 61 | "build": "npm run build:kernel && npm run build:desktop", 62 | "build:desktop": "cd desktop && rimraf dist && cross-env NODE_ENV=production npm run build", 63 | "build:kernel": "cd kernel && npm run build", 64 | "release": "npm run sync-version && git add . && git commit --message 'release' && git push --tags", 65 | "clean": "rimraf desktop/dist && rimraf kernel/dist && rm -rf dist", 66 | "dev": "concurrently 'npm run dev:desktop' 'npm run dev:kernel'", 67 | "dev:kernel": "cd kernel && npm run dev", 68 | "dev:desktop": "cd desktop && npm run dev", 69 | "package": "npm run package:desktop", 70 | "package:desktop": "cd desktop && npm run package", 71 | "package:electron": "npm run package:all", 72 | "package:all": "electron-builder -mwl", 73 | "package:mac": "electron-builder --mac --universal", 74 | "package:win": "electron-builder --win --x64 --ia32", 75 | "package:linux": "electron-builder --linux", 76 | "app:dir": "rm -rf dist && electron-builder --dir", 77 | "app:dist": "rm -rf dist && electron-builder", 78 | "sync-version": "cd desktop && npm version $npm_package_version && cd ../kernel && npm version $npm_package_version", 79 | "prepare": "husky" 80 | }, 81 | "devDependencies": { 82 | "@electron/notarize": "^3.0.1", 83 | "concurrently": "^9.1.2", 84 | "cross-env": "^7.0.3", 85 | "electron-builder": "^26.0.12", 86 | "husky": "^9.1.7", 87 | "lint-staged": "^15.5.0", 88 | "prettier": "^3.5.3", 89 | "rimraf": "^6.0.1", 90 | "rollup": "^4.34.9", 91 | "shx": "^0.4.0", 92 | "vite": "^6.2.2" 93 | }, 94 | "dependencies": { 95 | "classnames": "^2.5.1", 96 | "electron-log": "^5.3.2", 97 | "electron-updater": "^6.6.2", 98 | "glob": "^11.0.1", 99 | "lit-markdown": "^1.3.2" 100 | }, 101 | "optionalDependencies": { 102 | "@rollup/rollup-linux-x64-gnu": "4.9.5" 103 | }, 104 | "lint-staged": { 105 | "**/*": "prettier --write --ignore-unknown" 106 | } 107 | } 108 | --------------------------------------------------------------------------------