epic: Jan with new UI/UX (#4964)

* chore: initial new FE setup

* chore: update namespace text-left-panel foreground variable

* chore: enable dynamic mainview color

* chore: remove greetings new chat

* chore: fix chat input style

* chore: simplify hook useAppearance

* chore: enable internationalization

* chore: prepare vn locale

* chore: keyboardshortcut layout

* chore: update keyboard shortcut exclude pathname

* chore: update state active setting route

* chore: fix update theme by system

* chore: handle dynamic primary color

* chore: fix left panel navigation active state and styled item privacy analytic

* chore: reorder general setting being a first

* chore: add function reset appearance

* chore: update scrollbar

* chore: update delete thread with dialog confirmation

* chore: update state dialog inside dropdown menu

* chore: wip thread detail or chat page

* chore: wip model dropdown

* chore: prepare model dropdown select

* chore: update model providers setting

* chore: show provider on model dropdown based isActive toogle

* chore: update layout model provider

* chore: update state active on storage

* chore: update gap of item dropdown model

* chore: update select model base on id

* chore: update edit model capabilities

* chore: add dialog to add model

* chore: update sheet for model setting

* chore: add sheet setting each model

* chore: make dynamic syntax highlight

* chore: fix menu setting appearance theme

* chore: markdown render support emoji

* chore: markdown support latex

* chore: change codeblock default theme

* chore: update ui codeblock

* chore: custom render link taget new window

* chore: fix copy button codeblock

* chore: update accent and desctructive color

* chore: setup user chat message

* chore: prepare some page settings

* chore: simple list extension and prepare mcp, local api, and hardware

* chore: mcp-serve

* chore: MCP server UI

* chore: update local api server config

* chore: adjust chat input

* chore: update local api server log

* chore: prepare hub page

* chore: remove help page

* chore: update mock

* chore: prepare http proxy setting UI

* chore: adjust local api server and title every action

* fix: chore FE package (#4962)

* fix: update command which referred to non-existent web app

* fix: added commented out macos platform for now

* fix: remove the platform name as macos

* fix: remove unnecessary line for platform name in HeaderPage component

* fix: update dev script to specify port 3000 for Vite

* feat: model providers and chat completion

* enhancement: threads performance

* fix: thread content update

* chore: clean up threads

* fix: performance issue with streaming and state loop

* fix: streaming

* fix: react markdow

* feat: extension manager

* chore: add nodePolyfills include path

* chore: improve performance avoid unhandle rejection

* chore: update pre margin bottom

* chore: swith thread should be deafult scroll to bottom

* chore: wip scroll to bottom

* chore: add model loader

* chore: add platform utils

* feat: threads functionality

* chore: setup toaster

* chore: persist threads deletion

* fix: create thread with new message

* chore: create new thread should change route path

* chore: navigate after delet dialog thread

* chore: thread favorites and orders

* chore: dismiss deleting modal on delete

* chore: remove undefined properties

* chore: remove deprecated run step

* chore: fix delete thread

* chore: create empty thread content on started streaming

* chore: correct messages store key

* chore: stuck at generating state

* chore: preapre chat toolbar

* chore: introduce in-memory app state

* chore: update extensions migration logic

* chore: remove redundant extensions migration gate

* chore: message toolbar user and assistant

* chore: add logo gemini

* feat: remote providers with model capabilities

* chore: maintain provider settings

* chore: move speed token into chat input

* chore: temp harcoded model loader

* chore: make chat text selectable and truncate model list

* chore: update shortcut UI

* Feat/implement threads (#4977)

* chore: add fuse.js library for enhanced search functionality

* feat: implement thread filtering with Fuse.js for improved search capabilities

* fix: update the fuseOptions

* feat: add search functionality to LeftPanel and refactor thread retrieval logic

* refactor: optimize thread filtering and improve search functionality in LeftPanel

* fix: more edits

* refactor: remove duplicate import of useAppState in StreamingContent component

* chore: update navigate after delete all thread

* chore: pass prop speedToken from new chat input

* chore: persist provider general settings

* chore: styling search left panel

* chore: cleanup margin

* chore: update size icon

* chore: improve chat input

* chore: imprve list markdown

* chore: animate border

* feat: local model provider work

* chore: persist manually added model

* chore: prepare download management ui and show version on general setting

* chore: improve pre tag

* chore: remove buton install extension and improve light theme download

* chore: add missing hardware information handler

* chore: cleanup small ui

* chore: update default provider settings

* fix: missing fs commands

* chore: correct provider models

* chore: prepare delete model

* chore: handle thinking block

* chore: fix conditional message toolbar

* chore: pophover download select none

* enhancement: add prune mode

* chore: model settings

* chore: bump engine version tauri

* chore: update style thinking

* chore: add indicator and toogle mcp server

* chore: wip hub

* chore: update model settings

* chore: mvp hub

* chore: add function rename title

* chore: update function delete message

* chore: update rename title

* chore: update model settings

* chore: persist MCP configs

* refactor: clean up utils

* chore: add tools to completion request

* chore: clean up

* chore: ignore assets

---------

Co-authored-by: Ivan Leo <ivanleomk@gmail.com>
Co-authored-by: Louis <louis@jan.ai>
This commit is contained in:
Faisal Amir 2025-05-15 19:38:59 +07:00 committed by GitHub
parent e15a5ab599
commit 852ea84cd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
185 changed files with 16762 additions and 1671 deletions

View File

@ -26,7 +26,6 @@ endif
yarn install
yarn build:joi
yarn build:core
yarn build:server
yarn build:extensions
check-file-counts: install-and-build
@ -42,7 +41,7 @@ dev: check-file-counts
dev-tauri: check-file-counts
yarn install:cortex
yarn download:bin
yarn dev:tauri
CLEAN=true yarn dev:tauri
# Linting
lint: check-file-counts

View File

@ -8,31 +8,13 @@
],
"homepage": "https://jan.ai",
"license": "AGPL-3.0",
"browser": "dist/index.js",
"main": "dist/index.js",
"module": "dist/node/index.cjs.js",
"typings": "dist/types/index.d.ts",
"files": [
"dist",
"types"
],
"author": "Jan <service@jan.ai>",
"exports": {
".": "./dist/index.js",
"./node": "./dist/node/index.cjs.js"
},
"typesVersions": {
"*": {
".": [
"./dist/index.js.map",
"./dist/types/index.d.ts"
],
"node": [
"./dist/node/index.cjs.js.map",
"./dist/types/node/index.d.ts"
]
}
},
"scripts": {
"lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'",
"test": "jest",

View File

@ -80,10 +80,8 @@ const getGgufFiles: (paths: string[]) => Promise<any> = (paths) =>
* @param outsideJanDataFolder - Whether the file is outside the Jan data folder.
* @returns {Promise<FileStat>} - A promise that resolves with the file's stats.
*/
const fileStat: (path: string, outsideJanDataFolder?: boolean) => Promise<FileStat | undefined> = (
path,
outsideJanDataFolder
) => globalThis.core.api?.fileStat(path, outsideJanDataFolder)
const fileStat: (path: string) => Promise<FileStat | undefined> = (path) =>
globalThis.core.api?.fileStat({ args: path })
// TODO: Export `dummy` fs functions automatically
// Currently adding these manually

View File

@ -38,10 +38,13 @@ export class ModelManager {
return this.models.get(id) as T | undefined
}
/**
* The instance of the tool manager.
* Shared instance of ExtensionManager.
*/
static instance(): ModelManager {
return (window.core?.modelManager as ModelManager) ?? new ModelManager()
static instance() {
if (!window.core.modelManager)
window.core.modelManager = new ModelManager()
return window.core.modelManager as ModelManager
}
}

View File

@ -56,9 +56,6 @@ export default class JanEngineManagementExtension extends EngineManagementExtens
// Update default local engine
this.updateDefaultEngine()
// Populate default remote engines
this.populateDefaultRemoteEngines()
// Migrate
this.migrate()
}

View File

@ -26,12 +26,23 @@
"description": "Number of CPU cores used for model processing when running without GPU.",
"controllerType": "input",
"controllerProps": {
"value": "",
"value": "-1",
"placeholder": "Number of CPU threads",
"type": "number",
"textAlign": "right"
}
},
{
"key": "threads_batch",
"title": "Threads (Batch)",
"description": "Number of threads for batch and prompt processing (default: same as Threads).",
"controllerType": "input",
"controllerProps": {
"value": -1,
"placeholder": "-1 (same as Threads)",
"type": "number"
}
},
{
"key": "flash_attn",
"title": "Flash Attention",

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
"core",
"electron",
"web",
"web-app",
"server"
]
},
@ -20,7 +21,7 @@
"copy:assets:tauri": "cpx \"pre-install/*.tgz\" \"src-tauri/resources/pre-install/\" && cpx \"themes/**\" \"src-tauri/resources/themes\"",
"dev:electron": "yarn copy:assets && yarn workspace jan dev",
"dev:web:standalone": "concurrently \"yarn workspace @janhq/web dev\" \"wait-on http://localhost:3000 && rsync -av --prune-empty-dirs --include '*/' --include 'dist/***' --include 'package.json' --include 'tsconfig.json' --exclude '*' ./extensions/ web/.next/static/extensions/\"",
"dev:web": "yarn workspace @janhq/web dev",
"dev:web": "yarn workspace @janhq/web-app dev",
"dev:server": "yarn workspace @janhq/server dev",
"dev": "concurrently -n \"NEXT,ELECTRON\" -c \"yellow,blue\" --kill-others \"yarn dev:web\" \"yarn dev:electron\"",
"install:cortex:linux:darwin": "cd src-tauri/binaries && ./download.sh",
@ -34,7 +35,7 @@
"build:icon": "tauri icon ./src-tauri/icons/icon.png",
"build:server": "cd server && yarn build",
"build:core": "cd core && yarn build && yarn pack",
"build:web": "yarn workspace @janhq/web build && cpx \"web/out/**\" \"electron/renderer/\"",
"build:web": "yarn workspace @janhq/web-app build",
"build:electron": "yarn copy:assets && yarn workspace jan build",
"build:electron:test": "yarn workspace jan build:test",
"build:extensions": "rimraf ./pre-install/*.tgz || true && yarn workspace @janhq/core build && cd extensions && yarn install && yarn workspaces foreach -Apt run build:publish",

View File

@ -19,11 +19,12 @@ tauri-build = { version = "2.0.2", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.1.0", features = [ "protocol-asset", "macos-private-api",
tauri = { version = "2.4.0", features = [ "protocol-asset", "macos-private-api",
"test",
] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.2.0"
tauri-plugin-os = "2.2.1"
flate2 = "1.0"
tar = "0.4"
rand = "0.8"

View File

@ -9,9 +9,14 @@
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"shell:allow-spawn",
"shell:allow-open",
"core:app:allow-set-app-theme",
"core:window:allow-set-focus",
"os:default",
"log:default",
"core:webview:allow-create-webview-window",
{
"identifier": "http:default",
"allow": [

View File

@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "logs-window",
"description": "enables permissions for the logs window",
"windows": ["logs-window-local-api-server"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"log:default",
"core:webview:allow-create-webview-window",
"core:window:allow-set-focus"
]
}

View File

@ -50,6 +50,27 @@ pub fn exists_sync<R: Runtime>(
Ok(path.exists())
}
#[tauri::command]
pub fn file_stat<R: Runtime>(
app_handle: tauri::AppHandle<R>,
args: String,
) -> Result<String, String> {
if args.is_empty() {
return Err("file_stat error: Invalid argument".to_string());
}
let path = resolve_path(app_handle, &args);
let metadata = fs::metadata(&path).map_err(|e| e.to_string())?;
let is_directory = metadata.is_dir();
let file_size = if is_directory { 0 } else { metadata.len() };
// return { isDirectory, fileSize } object
let result = format!(
"{{\"isDirectory\": {}, \"fileSize\": {}}}",
is_directory, file_size
);
Ok(result)
}
#[tauri::command]
pub fn read_file_sync<R: Runtime>(
app_handle: tauri::AppHandle<R>,

View File

@ -33,9 +33,6 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), Stri
.clone()
.unwrap_or_else(|| "".to_string());
if !force && stored_version == app_version {
return Ok(());
}
let extensions_path = get_jan_extensions_path(app.clone());
let pre_install_path = app
.path()
@ -44,6 +41,16 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), Stri
.join("resources")
.join("pre-install");
let mut clean_up = force;
// Check CLEAN environment variable to optionally skip extension install
if std::env::var("CLEAN").is_ok() {
clean_up = true;
}
if !clean_up && stored_version == app_version && extensions_path.exists() {
return Ok(());
}
// Attempt to remove extensions folder
if extensions_path.exists() {
fs::remove_dir_all(&extensions_path).unwrap_or_else(|_| {
@ -51,10 +58,6 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), Stri
});
}
if !force {
return Ok(());
};
// Attempt to create it again
if !extensions_path.exists() {
fs::create_dir_all(&extensions_path).map_err(|e| e.to_string())?;
@ -197,7 +200,9 @@ pub fn setup_mcp(app: &App) {
if let Err(e) = run_mcp_commands(app_path_str, servers).await {
log::error!("Failed to run mcp commands: {}", e);
}
app_handle.emit("mcp-update", "MCP servers updated").unwrap();
app_handle
.emit("mcp-update", "MCP servers updated")
.unwrap();
});
}
@ -217,7 +222,7 @@ pub fn setup_sidecar(app: &App) -> Result<(), String> {
"--cors",
"ON",
"--allowed_origins",
"http://localhost:3000,tauri://localhost,http://tauri.localhost",
"http://localhost:3000,http://localhost:1420",
"config",
"--api_keys",
app_state.inner().app_token.as_deref().unwrap_or(""),

View File

@ -14,6 +14,7 @@ use reqwest::blocking::Client;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_shell::init())
@ -25,6 +26,7 @@ pub fn run() {
core::fs::readdir_sync,
core::fs::read_file_sync,
core::fs::rm,
core::fs::file_stat,
// App commands
core::cmd::get_themes,
core::cmd::get_app_configurations,

View File

@ -4,8 +4,8 @@
"version": "0.5.16",
"identifier": "jan.ai.app",
"build": {
"frontendDist": "../web/out",
"devUrl": "http://localhost:3000",
"frontendDist": "../web-app/dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "cross-env IS_TAURI=true yarn dev:web",
"beforeBuildCommand": "cross-env IS_TAURI=true yarn build:web"
},
@ -13,22 +13,25 @@
"macOSPrivateApi": true,
"windows": [
{
"label": "main",
"title": "Jan",
"width": 1024,
"height": 768,
"height": 800,
"resizable": true,
"fullscreen": false,
"center": true,
"hiddenTitle": true,
"transparent": true,
"trafficLightPosition": {
"x": 12,
"y": 22
},
"decorations": true,
"titleBarStyle": "Overlay",
"windowEffects": {
"effects": [
"fullScreenUI",
"mica",
"blur",
"acrylic"
],
"state": "active"
"effects": ["fullScreenUI", "mica", "blur", "acrylic"],
"state": "active",
"radius": 8
}
}
],
@ -55,6 +58,10 @@
}
},
"plugins": {
"os": {
"version": "latest",
"resolve": true
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJFNDEzMEVCMUEzNUFENDQKUldSRXJUVWE2ekJCTGc1Mm1BVXgrWmtES3huUlBFR0lCdG5qbWFvMzgyNDhGN3VTTko5Q1NtTW0K",
"endpoints": [

24
web-app/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
web-app/README.md Normal file
View File

@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

21
web-app/components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

28
web-app/eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
web-app/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

87
web-app/package.json Normal file
View File

@ -0,0 +1,87 @@
{
"name": "@janhq/web-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@janhq/core": "link:../core",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.11",
"@radix-ui/react-hover-card": "^1.1.11",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tooltip": "^1.2.4",
"@tabler/icons-react": "^3.31.0",
"@tailwindcss/vite": "^4.1.4",
"@tanstack/react-router": "^1.116.0",
"@tanstack/react-router-devtools": "^1.116.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-os": "^2.2.1",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"@uiw/react-textarea-code-editor": "^3.1.1",
"class-variance-authority": "^0.7.1",
"culori": "^4.0.1",
"fuse.js": "^7.1.0",
"i18next": "^25.0.1",
"katex": "^0.16.22",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.503.0",
"motion": "^12.10.5",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.0.0",
"react-i18next": "^15.5.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1",
"react-syntax-highlighter-virtualized-renderer": "^1.1.0",
"react-textarea-autosize": "^8.5.9",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-emoji": "^5.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.3",
"tailwindcss": "^4.1.4",
"token.js": "npm:token.js-fork@0.7.5",
"tw-animate-css": "^1.2.7",
"ulidx": "^2.4.1",
"unified": "^11.0.5",
"uuid": "^11.1.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@tanstack/router-plugin": "^1.116.1",
"@types/culori": "^2.1.1",
"@types/lodash.debounce": "^4",
"@types/node": "^22.14.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"clsx": "^2.1.1",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tailwind-merge": "^3.2.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.0",
"vite-plugin-node-polyfills": "^0.23.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1,14 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 13.3371C16 14.1449 15.5635 14.8 15.0254 14.8H0.974573C0.436327 14.8 0 14.1449 0 13.3371V3.46307C0 2.65488 0.436327 2 0.974573 2H15.0254C15.5635 2 16 2.65488 16 3.46307V13.3371Z" fill="#98CBF9"/>
<path d="M15.0505 2.33301H1.36558C0.841125 2.33301 0.41626 2.97078 0.41626 3.75796V13.3751C0.41626 14.1618 0.841125 14.7998 1.36558 14.7998H15.0252C15.5634 14.7998 16 14.1447 16 13.3369V3.75796C16 2.97078 15.5749 2.33301 15.0505 2.33301Z" fill="#98CBF9"/>
<path d="M0.959961 3.43996C0.959961 3.17486 1.17486 2.95996 1.43996 2.95996H14.56C14.8251 2.95996 15.04 3.17486 15.04 3.43996V13.36C15.04 13.6251 14.8251 13.84 14.56 13.84H1.43996C1.17486 13.84 0.959961 13.6251 0.959961 13.36V3.43996Z" stroke="#202020" stroke-width="0.32" stroke-miterlimit="10"/>
<path d="M1.92004 3.91992H14.08V12.8799H1.92004V3.91992Z" fill="#202020"/>
<path d="M5.11995 7.75984H3.19995V5.83984H5.11995V7.75984Z" fill="white"/>
<path d="M3.84009 7.75998H5.12009V6.47998H3.84009V7.75998Z" fill="#98CBF9"/>
<path d="M5.11995 7.75984H3.19995V5.83984H5.11995V7.75984Z" fill="white"/>
<path d="M3.84009 7.75998H5.12009V6.47998H3.84009V7.75998Z" fill="#98CBF9"/>
<path d="M12.8 7.75984H10.88V5.83984H12.8V7.75984Z" fill="white"/>
<path d="M11.5199 7.75998H12.7999V6.47998H11.5199V7.75998Z" fill="#98CBF9"/>
<path d="M12.8 11.5999H3.19995V9.67993H12.8V11.5999Z" fill="white"/>
<path d="M12.8001 11.5998H3.84009V10.3198H12.8001V11.5998Z" fill="#98CBF9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 56.2 41.4" style="enable-background:new 0 0 56.2 41.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4D6BFE;}
</style>
<desc>Created with Pixso.</desc>
<path id="path_00000023980838171174838950000018388351879808150157_" class="st0" d="M55.6,3.5C55,3.2,54.8,3.7,54.4,4
c-0.1,0.1-0.2,0.2-0.3,0.3c-0.9,0.9-1.9,1.5-3.2,1.5c-1.9-0.1-3.6,0.5-5.1,2c-0.3-1.8-1.3-2.9-2.9-3.6c-0.8-0.4-1.7-0.7-2.2-1.5
c-0.4-0.6-0.5-1.2-0.7-1.8c-0.1-0.4-0.3-0.8-0.7-0.8c-0.5-0.1-0.7,0.3-0.8,0.6c-0.7,1.3-1,2.8-1,4.3c0.1,3.4,1.5,6,4.3,7.9
c0.3,0.2,0.4,0.4,0.3,0.8c-0.2,0.7-0.4,1.3-0.6,1.9c-0.1,0.4-0.3,0.5-0.8,0.3c-1.5-0.6-2.9-1.6-4.1-2.8c-2-1.9-3.8-4.1-6.1-5.8
C30,7,29.4,6.7,28.9,6.3c-2.3-2.2,0.3-4.1,0.9-4.3C30.4,1.8,30,1,28,1c-2,0-3.9,0.7-6.3,1.6c-0.3,0.1-0.7,0.2-1.1,0.3
c-2.2-0.4-4.4-0.5-6.8-0.2C9.4,3.2,5.9,5.3,3.3,8.8C0.2,13.1-0.5,18,0.4,23.1c0.9,5.3,3.7,9.8,7.9,13.2c4.4,3.6,9.4,5.3,15.1,5
c3.5-0.2,7.3-0.7,11.7-4.4c1.1,0.5,2.3,0.8,4.2,0.9c1.5,0.1,2.9-0.1,4-0.3c1.7-0.4,1.6-2,1-2.3c-5-2.4-3.9-1.4-4.9-2.2
c2.6-3,6.4-6.2,7.9-16.4c0.1-0.8,0-1.3,0-2c0-0.4,0.1-0.6,0.5-0.6c1.3-0.1,2.5-0.5,3.6-1.1c3.3-1.8,4.6-4.7,4.9-8.2
C56.2,4.3,56.2,3.8,55.6,3.5z M27.1,35.1c-4.9-3.8-7.3-5.1-8.2-5.1c-0.9,0.1-0.8,1.1-0.5,1.8c0.2,0.7,0.5,1.1,0.9,1.7
c0.3,0.4,0.4,1-0.3,1.4c-1.6,1-4.3-0.3-4.4-0.4c-3.2-1.9-5.9-4.4-7.7-7.7c-1.8-3.3-2.9-6.8-3-10.5c0-0.9,0.2-1.2,1.1-1.4
c1.2-0.2,2.4-0.3,3.6-0.1c5,0.7,9.2,3,12.8,6.5c2,2,3.6,4.4,5.2,6.8c1.7,2.5,3.5,4.9,5.8,6.8c0.8,0.7,1.5,1.2,2.1,1.6
C32.4,36.8,29.3,36.8,27.1,35.1z M29.5,20c0-0.4,0.3-0.7,0.7-0.7c0.1,0,0.2,0,0.2,0c0.1,0,0.2,0.1,0.3,0.2c0.1,0.1,0.2,0.3,0.2,0.5
c0,0.4-0.3,0.7-0.7,0.7C29.8,20.8,29.5,20.4,29.5,20z M36.7,23.8c-0.5,0.2-0.9,0.4-1.4,0.4c-0.7,0-1.5-0.2-1.9-0.6
c-0.6-0.5-1.1-0.8-1.3-1.8c-0.1-0.4,0-1,0-1.4c0.2-0.8,0-1.3-0.6-1.7c-0.4-0.4-1-0.5-1.6-0.5c-0.2,0-0.4-0.1-0.6-0.2
c-0.3-0.1-0.5-0.4-0.3-0.8c0.1-0.1,0.4-0.4,0.4-0.5c0.8-0.5,1.8-0.3,2.7,0c0.8,0.3,1.4,1,2.3,1.8c0.9,1.1,1.1,1.3,1.6,2.1
c0.4,0.6,0.8,1.3,1,2C37.5,23.2,37.3,23.6,36.7,23.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="7" cy="7" r="3" fill="#18181B"/>
</svg>

After

Width:  |  Height:  |  Size: 148 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M16 8.016A8.522 8.522 0 008.016 16h-.032A8.521 8.521 0 000 8.016v-.032A8.521 8.521 0 007.984 0h.032A8.522 8.522 0 0016 7.984v.032z" fill="url(#prefix__paint0_radial_980_20147)"/><defs><radialGradient id="prefix__paint0_radial_980_20147" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(16.1326 5.4553 -43.70045 129.2322 1.588 6.503)"><stop offset=".067" stop-color="#9168C0"/><stop offset=".343" stop-color="#5684D1"/><stop offset=".672" stop-color="#1BA1E3"/></radialGradient></defs></svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M16 8.016A8.522 8.522 0 008.016 16h-.032A8.521 8.521 0 000 8.016v-.032A8.521 8.521 0 007.984 0h.032A8.522 8.522 0 0016 7.984v.032z" fill="url(#prefix__paint0_radial_980_20147)"/><defs><radialGradient id="prefix__paint0_radial_980_20147" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(16.1326 5.4553 -43.70045 129.2322 1.588 6.503)"><stop offset=".067" stop-color="#9168C0"/><stop offset=".343" stop-color="#5684D1"/><stop offset=".672" stop-color="#1BA1E3"/></radialGradient></defs></svg>

After

Width:  |  Height:  |  Size: 599 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -0,0 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="16" height="16" rx="8" fill="url(#pattern0_3571_159)"/>
<defs>
<pattern id="pattern0_3571_159" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_3571_159" transform="scale(0.00497512)"/>
</pattern>
<image id="image0_3571_159" width="201" height="201" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMkAAADJCAMAAAC+GOY3AAAAQlBMVEX1TzX1TzX1TzX1TzX1TzX2WkL4e2j6p5r7sqf9083+9PL////+6eb5kYH4hnT7vbP3cFv8yMD93tn2ZU75nI31TzW9T89eAAAABXRSTlOPv2CAQLFEbcUAAAUSSURBVHic7ZzrmqMgDIbnsIra2mpb7/9Wt4c5dElQMGH57JPv90zlFXKQQN7e3l9DH2/v02vo00jgZCR4MhI8GQmejARPRoInI8GTkeDJSPBkJHgyEjwZCZ6MBE9GgicjwZOR4MlI8GQkeDKSKFW1a9q22920b1vX1/melY+kOhz3O6rWDXmel4vkMDIUX9o3WWBykFSuC3PcderVH5qBpDouYDwmRp1Fm2R5Pn5YDqoP1iapOSsPqT1rPlqVpGoSOK7qNJeYJsmQMiEPHSu1pyuS9LEW8qyTmkfWI3ErOK7qtFDUSKJ8L4ui5MO0SFaDXKVj90okEhClBaZDcpGAKKGokBxkINd4r+CMNUjOYffbja5+vPD64NowSisehArJKYRB0vdwsu/Eo1AgCQQSPts9hzJMsanISQZ+Pi6hvw9k/SfhMBRI2LU1zpkwnzBL15eYpOdGFZyQh9hp6YT+S0zCvN+I8MDZ1lE2ECkJMyVRcY6bStmHl5SETklkwGZQGtFIhCTMeGJ356ityCxFSELDdrwLok5PlBTLSM5kMAlpB01yRDFFRkJz4BSrlf23LxkJWSBp4Y24i4U4NCsRCVlciTZbay4vEQnxXKkZB5kUgfcSkRBHmrrOyasQFFhEJP4rHVN/oJJO6pNEJP440uOB/+Ul+HaUkBCDTXei/vLarx6MiMTfiFgxDPKZtnowIhI/NU82k4ku0PWxUZNkjbn6idt65yUh8Z3wmgiNQaIxCn9ejcRIHnodEj9Ar7F4DBINL4xJsiYyYpBoJE0YJAoZJAiJQlaPQiL+0oIhEX/9wpCQDat0PwxCIt0lmmBI6C5P8qSgkJDzXF2qpaCQ0Gpp6uYICglTOUhMI2FImEpQWmEdhmSi5wTSjtPgkDBF3CQUHJKKObyRgoJDwh7tSjgQCETCHS3Y7ZrYYI9EQr5S7oo9a45EQgP9Q23UoKBIqtBJtf1lOXeBIpmG8OnBU7NwowmLhD8ZFdbzaMFIQqayQZK0k8/QJEko2CQpKOAkCSjoJPEeDJ5kqiMvBeGTTNXMzdJtkUTe1doESdRFwG2QTNN50YltheTGMr/GtkNyXWN9KNPfGslV534MzczzaP2lCEhy09C77+YLodFinFvRkL8Kt0viz9f6XypMQjZm1v9UYRLfdRU60akg30wE94LKkpBCpeBgfVkSkqQJbjUWJSEb/Z3gx4qSkOKL5PpcSRJae5FcbypJQtN/yZWzgiT0Vr3obmY5EuZWvai9VzESplYhuOgwlSPhii6yHh8ZSIZ22XA5ENmUZCAZuuUyNlsGE7ZdUSe5F7gWmkCxm5XSpgXaJN+VupmmBYEtMWlfQmWS35Jj5wIsPd8bS3alfNIm+ad2yrFUAQ6F3jGqJKQIPPbPdeyqD2/pyXteqvZU48a5b52r69q5cW4zT3J1+UuKJDNl+SUJm2HcpUciADlpdLtTIykNokcy0wjqv4AUqf1mAdG0+HUosx2MUqTphRMP4twlDu0/0o2MqU06tToo3qSbrUSWsb+l2gJWOxdOaGar2v81x5dWZIPhYK68Vhm+fmOaJXeNam/hm7LsSMyXfm9HPZXn46ZceytDEzKYTK3Rc+4SnXvSsX4/XnK1q8++31UfnHNt2zbOHeoMa+pXpWu/ejISPBkJnowET0aCJyPBk5HgyUjwZCR4MhI8GQmejARPRoInI8GTkeDJSPBkJHgyEjwZCZ5eieTj8zX05y/Z/fRFIEbR7gAAAABJRU5ErkJggg=="/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3571_178)">
<path d="M9.36718 16C12.8752 16 15.719 13.0785 15.719 9.47471C15.719 5.87092 12.8752 2.94946 9.36718 2.94946C5.85918 2.94946 3.01538 5.87092 3.01538 9.47471C3.01538 13.0785 5.85918 16 9.36718 16Z" fill="#FF563F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.97746 10.1876C4.96849 12.1786 8.1966 12.1786 10.1876 10.1876C12.1786 8.1966 12.1786 4.96849 10.1876 2.97746C8.1966 0.986425 4.96849 0.986425 2.97746 2.97746C0.986425 4.96849 0.986425 8.1966 2.97746 10.1876ZM11.2371 11.2371C8.66645 13.8077 4.49863 13.8077 1.92798 11.2371C-0.64266 8.66645 -0.64266 4.49863 1.92798 1.92798C4.49863 -0.64266 8.66645 -0.64266 11.2371 1.92798C13.8077 4.49863 13.8077 8.66645 11.2371 11.2371Z" fill="#0C0C0C"/>
</g>
<defs>
<clipPath id="clip0_3571_178">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 951 B

View File

@ -0,0 +1,72 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.59752 3.00269C4.59223 3.00269 4.58695 3.00269 4.58167 3.00269L4.56055 4.73761C4.56583 4.73761 4.57111 4.73761 4.57375 4.73761C5.71716 4.73761 6.60443 5.63808 8.53477 8.88084L8.6536 9.07889L8.66152 9.09209L9.74156 7.47599L9.73363 7.46279C9.48013 7.05084 9.23455 6.67059 9.00217 6.32466C8.73018 5.92328 8.47139 5.56414 8.21789 5.24462C6.94244 3.6206 5.87296 3.00269 4.59752 3.00269Z" fill="url(#paint0_linear_3534_109)"/>
<path d="M4.5817 3.00264C3.29833 3.00792 2.16548 3.8371 1.34423 5.10462C1.34159 5.10726 1.33894 5.11254 1.3363 5.11518L2.83885 5.93115C2.84149 5.92851 2.84413 5.92323 2.84677 5.92059C3.32473 5.20233 3.92153 4.74285 4.55793 4.73493C4.56321 4.73493 4.56849 4.73493 4.57113 4.73493L4.59226 3C4.59226 3.00264 4.58698 3.00264 4.5817 3.00264Z" fill="url(#paint1_linear_3534_109)"/>
<path d="M1.34687 5.10449C1.34423 5.10713 1.34159 5.11241 1.33895 5.11505C0.800247 5.95215 0.398864 6.97673 0.182328 8.08582C0.182328 8.0911 0.179688 8.09638 0.179688 8.09902L1.86972 8.49776C1.86972 8.49248 1.87236 8.4872 1.87236 8.48456C2.05193 7.51015 2.39786 6.60968 2.84413 5.93366C2.84677 5.93102 2.84941 5.92574 2.85205 5.9231L1.34687 5.10449Z" fill="url(#paint2_linear_3534_109)"/>
<path d="M1.87249 8.48444L0.182451 8.08569C0.182451 8.09097 0.17981 8.09626 0.17981 8.0989C0.0609798 8.70889 0.000244141 9.32681 0.000244141 9.94737C0.000244141 9.95265 0.000244141 9.95793 0.000244141 9.96322L1.73253 10.119C1.73253 10.1137 1.73253 10.1085 1.73253 10.1032C1.73253 10.0662 1.73253 10.0319 1.73253 9.9949C1.73253 9.49317 1.78006 8.99145 1.86984 8.50028C1.86984 8.49236 1.86984 8.48972 1.87249 8.48444Z" fill="url(#paint3_linear_3534_109)"/>
<path d="M1.78525 10.6581C1.75356 10.4785 1.73772 10.299 1.73244 10.1194C1.73244 10.1141 1.73244 10.1088 1.73244 10.1036L0.000152346 9.94775C0.000152346 9.95304 0.000152346 9.95832 0.000152346 9.9636C-0.00248833 10.3254 0.0292 10.6871 0.0952169 11.0463C0.0952169 11.0516 0.0978575 11.0568 0.0978575 11.0595L1.78789 10.6713C1.78525 10.6687 1.78525 10.6634 1.78525 10.6581Z" fill="url(#paint4_linear_3534_109)"/>
<path d="M2.17871 11.5531C1.99122 11.3472 1.85655 11.0514 1.78789 10.6712C1.78789 10.6659 1.78525 10.6606 1.78525 10.658L0.0952148 11.0461C0.0952148 11.0514 0.0978555 11.0567 0.0978555 11.0593C0.224608 11.7301 0.475472 12.2873 0.834605 12.7098C0.837245 12.7124 0.839886 12.7177 0.845167 12.7203L2.18927 11.5637C2.18663 11.5611 2.18399 11.5558 2.17871 11.5531Z" fill="url(#paint5_linear_3534_109)"/>
<path d="M7.18547 6.75244C6.16617 8.31308 5.55089 9.29013 5.55089 9.29013C4.19358 11.4132 3.72618 11.8886 2.97095 11.8886C2.65671 11.8886 2.39264 11.7777 2.19195 11.5611C2.18931 11.5585 2.18403 11.5532 2.18139 11.5506L0.83728 12.7072C0.839921 12.7098 0.842562 12.7151 0.847843 12.7177C1.34429 13.2934 2.04143 13.6129 2.90757 13.6129C4.21735 13.6129 5.15743 12.9976 6.83162 10.0771C6.83162 10.0771 7.52876 8.8465 8.00936 7.99884C7.70568 7.52616 7.43634 7.11157 7.18547 6.75244Z" fill="#0082FB"/>
<path d="M9.00218 4.29126C8.99954 4.2939 8.99426 4.29918 8.99161 4.30182C8.71698 4.60022 8.4582 4.91446 8.2179 5.2419C8.46876 5.56143 8.73019 5.92056 9.00218 6.32458C9.3217 5.83077 9.6201 5.43203 9.91321 5.12571C9.91585 5.12307 9.92113 5.11779 9.92377 5.11515L9.00218 4.29126Z" fill="url(#paint6_linear_3534_109)"/>
<path d="M13.9456 4.13818C13.2353 3.42255 12.3876 3.00269 11.4845 3.00269C10.5312 3.00269 9.72581 3.52554 9.00226 4.29134C8.99962 4.29398 8.99434 4.29926 8.9917 4.3019L9.9133 5.12315C9.91594 5.12051 9.92122 5.11523 9.92386 5.11259C10.4018 4.61614 10.8639 4.37056 11.3736 4.37056C11.9255 4.37056 12.4404 4.62934 12.8867 5.08354C12.8893 5.08618 12.8946 5.09146 12.8973 5.0941L13.9562 4.14874C13.9535 4.1461 13.9483 4.14346 13.9456 4.13818Z" fill="#0082FB"/>
<path d="M15.9974 9.72058C15.9578 7.42055 15.1524 5.36346 13.9535 4.14875C13.9509 4.14611 13.9456 4.14082 13.9429 4.13818L12.884 5.08355C12.8867 5.08619 12.892 5.09147 12.8946 5.09411C13.7951 6.01835 14.413 7.73479 14.4684 9.72058C14.4684 9.72586 14.4684 9.73114 14.4684 9.73642H15.9974C16 9.73114 15.9974 9.72586 15.9974 9.72058Z" fill="url(#paint7_linear_3534_109)"/>
<path d="M16 9.7363C16 9.73102 16 9.72574 16 9.72046H14.4711C14.4711 9.72574 14.4711 9.73102 14.4711 9.7363C14.4737 9.82873 14.4737 9.92379 14.4737 10.0162C14.4737 10.5576 14.3919 10.9959 14.2281 11.3101C14.2255 11.3154 14.2229 11.3207 14.2202 11.3234L15.3583 12.5064C15.361 12.5011 15.3636 12.4985 15.3663 12.4932C15.7809 11.8568 15.9974 10.9721 15.9974 9.89738C16 9.84457 16 9.78912 16 9.7363Z" fill="url(#paint8_linear_3534_109)"/>
<path d="M14.2282 11.3101C14.2256 11.3153 14.2229 11.3206 14.2203 11.3233C14.0777 11.59 13.8744 11.7669 13.6077 11.8435L14.1279 13.4781C14.1965 13.4543 14.2626 13.4279 14.3286 13.3988C14.3471 13.3909 14.3682 13.3804 14.3867 13.3724C14.3972 13.3671 14.4078 13.3619 14.4184 13.3566C14.7643 13.1823 15.0627 12.9262 15.2871 12.6093C15.3003 12.5881 15.3162 12.5697 15.3294 12.5485C15.34 12.5353 15.3479 12.5195 15.3584 12.5063C15.3611 12.501 15.3637 12.4984 15.3664 12.4931L14.2282 11.3101Z" fill="url(#paint9_linear_3534_109)"/>
<path d="M13.2801 11.891C13.1164 11.8936 12.9527 11.862 12.8021 11.7986L12.2714 13.4728C12.5698 13.5758 12.8893 13.6207 13.2458 13.6207C13.5521 13.6233 13.8558 13.5758 14.1462 13.4781L13.626 11.8435C13.5125 11.8752 13.3963 11.891 13.2801 11.891Z" fill="url(#paint10_linear_3534_109)"/>
<path d="M12.216 11.3182C12.2133 11.3155 12.2107 11.3103 12.2054 11.3076L10.9828 12.5778C10.9854 12.5804 10.9907 12.5857 10.9934 12.5883C11.4185 13.0399 11.8252 13.3225 12.2846 13.4756L12.8154 11.8041C12.6226 11.7196 12.4325 11.569 12.216 11.3182Z" fill="url(#paint11_linear_3534_109)"/>
<path d="M12.2054 11.3074C11.8383 10.8823 11.3841 10.1719 10.6711 9.02588L9.73896 7.47316L9.73104 7.45996L8.651 9.07606L8.65892 9.08926L9.31909 10.1957C9.95814 11.2625 10.4784 12.0363 10.9827 12.575C10.9854 12.5776 10.9906 12.5829 10.9933 12.5855L12.2159 11.3154C12.2133 11.3154 12.208 11.3101 12.2054 11.3074Z" fill="url(#paint12_linear_3534_109)"/>
<defs>
<linearGradient id="paint0_linear_3534_109" x1="9.01739" y1="8.43763" x2="5.44023" y2="3.74298" gradientUnits="userSpaceOnUse">
<stop offset="0.0006" stop-color="#0867DF"/>
<stop offset="0.4539" stop-color="#0668E1"/>
<stop offset="0.8591" stop-color="#0064E0"/>
</linearGradient>
<linearGradient id="paint1_linear_3534_109" x1="2.04645" y1="5.39837" x2="4.50086" y2="3.5321" gradientUnits="userSpaceOnUse">
<stop offset="0.1323" stop-color="#0064DF"/>
<stop offset="0.9988" stop-color="#0064E0"/>
</linearGradient>
<linearGradient id="paint2_linear_3534_109" x1="1.0039" y1="8.12949" x2="1.98693" y2="5.65105" gradientUnits="userSpaceOnUse">
<stop offset="0.0147" stop-color="#0072EC"/>
<stop offset="0.6881" stop-color="#0064DF"/>
</linearGradient>
<linearGradient id="paint3_linear_3534_109" x1="0.869701" y1="9.91881" x2="0.983398" y2="8.40577" gradientUnits="userSpaceOnUse">
<stop offset="0.0731" stop-color="#007CF6"/>
<stop offset="0.9943" stop-color="#0072EC"/>
</linearGradient>
<linearGradient id="paint4_linear_3534_109" x1="0.932222" y1="10.741" x2="0.850892" y2="10.1348" gradientUnits="userSpaceOnUse">
<stop offset="0.0731" stop-color="#007FF9"/>
<stop offset="1" stop-color="#007CF6"/>
</linearGradient>
<linearGradient id="paint5_linear_3534_109" x1="0.884741" y1="10.8998" x2="1.39186" y2="11.9771" gradientUnits="userSpaceOnUse">
<stop offset="0.0731" stop-color="#007FF9"/>
<stop offset="1" stop-color="#0082FB"/>
</linearGradient>
<linearGradient id="paint6_linear_3534_109" x1="8.69922" y1="5.69107" x2="9.36998" y2="4.76191" gradientUnits="userSpaceOnUse">
<stop offset="0.2799" stop-color="#007FF8"/>
<stop offset="0.9141" stop-color="#0082FB"/>
</linearGradient>
<linearGradient id="paint7_linear_3534_109" x1="13.8102" y1="4.48732" x2="15.2122" y2="9.65292" gradientUnits="userSpaceOnUse">
<stop stop-color="#0082FB"/>
<stop offset="0.9995" stop-color="#0081FA"/>
</linearGradient>
<linearGradient id="paint8_linear_3534_109" x1="15.5531" y1="9.84896" x2="14.6655" y2="11.6455" gradientUnits="userSpaceOnUse">
<stop offset="0.0619" stop-color="#0081FA"/>
<stop offset="1" stop-color="#0080F9"/>
</linearGradient>
<linearGradient id="paint9_linear_3534_109" x1="13.9578" y1="12.5974" x2="14.7888" y2="12.0305" gradientUnits="userSpaceOnUse">
<stop stop-color="#027AF3"/>
<stop offset="1" stop-color="#0080F9"/>
</linearGradient>
<linearGradient id="paint10_linear_3534_109" x1="12.6527" y1="12.7075" x2="13.8086" y2="12.7075" gradientUnits="userSpaceOnUse">
<stop stop-color="#0377EF"/>
<stop offset="0.9994" stop-color="#0279F1"/>
</linearGradient>
<linearGradient id="paint11_linear_3534_109" x1="11.6481" y1="12.0716" x2="12.4784" y2="12.5608" gradientUnits="userSpaceOnUse">
<stop offset="0.0019" stop-color="#0471E9"/>
<stop offset="1" stop-color="#0377EF"/>
</linearGradient>
<linearGradient id="paint12_linear_3534_109" x1="9.11475" y1="8.47073" x2="11.7718" y2="11.8166" gradientUnits="userSpaceOnUse">
<stop offset="0.2765" stop-color="#0867DF"/>
<stop offset="1" stop-color="#0471E9"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -0,0 +1,28 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.909 2H10.7272V4.39719H12.909V2Z" fill="black"/>
<path d="M14.0001 2H11.8182V4.39719H14.0001V2Z" fill="#F7D046"/>
<path d="M4.18182 2H2V4.39719H4.18182V2Z" fill="black"/>
<path d="M4.18182 4.39722H2V6.79441H4.18182V4.39722Z" fill="black"/>
<path d="M4.18182 6.79419H2V9.19138H4.18182V6.79419Z" fill="black"/>
<path d="M4.18182 9.19165H2V11.5888H4.18182V9.19165Z" fill="black"/>
<path d="M4.18182 11.5889H2V13.9861H4.18182V11.5889Z" fill="black"/>
<path d="M5.27276 2H3.09094V4.39719H5.27276V2Z" fill="#F7D046"/>
<path d="M14.0001 4.39722H11.8182V6.79441H14.0001V4.39722Z" fill="#F2A73B"/>
<path d="M5.27276 4.39722H3.09094V6.79441H5.27276V4.39722Z" fill="#F2A73B"/>
<path d="M10.7272 4.39722H8.54541V6.79441H10.7272V4.39722Z" fill="black"/>
<path d="M11.8182 4.39722H9.63635V6.79441H11.8182V4.39722Z" fill="#F2A73B"/>
<path d="M7.45465 4.39722H5.27283V6.79441H7.45465V4.39722Z" fill="#F2A73B"/>
<path d="M9.63641 6.79419H7.45459V9.19138H9.63641V6.79419Z" fill="#EE792F"/>
<path d="M11.8182 6.79419H9.63635V9.19138H11.8182V6.79419Z" fill="#EE792F"/>
<path d="M7.45465 6.79419H5.27283V9.19138H7.45465V6.79419Z" fill="#EE792F"/>
<path d="M8.54547 9.19165H6.36365V11.5888H8.54547V9.19165Z" fill="black"/>
<path d="M9.63641 9.19165H7.45459V11.5888H9.63641V9.19165Z" fill="#EB5829"/>
<path d="M14.0001 6.79419H11.8182V9.19138H14.0001V6.79419Z" fill="#EE792F"/>
<path d="M5.27276 6.79419H3.09094V9.19138H5.27276V6.79419Z" fill="#EE792F"/>
<path d="M12.909 9.19165H10.7272V11.5888H12.909V9.19165Z" fill="black"/>
<path d="M14.0001 9.19165H11.8182V11.5888H14.0001V9.19165Z" fill="#EB5829"/>
<path d="M12.909 11.5889H10.7272V13.9861H12.909V11.5889Z" fill="black"/>
<path d="M5.27276 9.19165H3.09094V11.5888H5.27276V9.19165Z" fill="#EB5829"/>
<path d="M14.0001 11.5889H11.8182V13.9861H14.0001V11.5889Z" fill="#EA3326"/>
<path d="M5.27276 11.5889H3.09094V13.9861H5.27276V11.5889Z" fill="#EA3326"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,6 @@
<svg width="12" height="24" viewBox="0 0 12 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.0949 10.247L9.3209 0L2.0899 11.224L0.399902 13.753H3.6609H5.5149L1.7559 24L9.5209 12.776L11.2099 10.247H7.9489H6.0949Z" fill="#FFDC75"/>
<path d="M5.695 10.247L9.321 0L1.69 11.224L0 13.753H3.261H5.115L1.756 24L9.121 12.776L10.81 10.247H7.549H5.695Z" fill="#FEC928"/>
<path d="M0 13.753H1L2.69 11.224L9.321 0L1.69 11.224L0 13.753Z" fill="#EDA703"/>
<path d="M1.75586 23.9999L6.11486 13.7529H5.11486L1.75586 23.9999Z" fill="#EDA703"/>
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3572_51)">
<path d="M5.966 6.15551V5.20179C6.06019 5.19591 6.15439 5.19002 6.24858 5.19002C8.86247 5.1076 10.5756 7.43891 10.5756 7.43891C10.5756 7.43891 8.72707 10.0057 6.7431 10.0057C6.47818 10.0057 6.21915 9.96449 5.97189 9.88207V6.9856C6.99036 7.10923 7.19641 7.55665 7.80279 8.57513L9.16272 7.43302C9.16272 7.43302 8.16779 6.13196 6.49584 6.13196C6.31923 6.12607 6.14261 6.13785 5.966 6.15551ZM5.966 3V4.42469L6.24858 4.40703C9.88095 4.2834 12.2535 7.38592 12.2535 7.38592C12.2535 7.38592 9.53361 10.6945 6.70189 10.6945C6.45463 10.6945 6.21326 10.671 5.97189 10.6297V11.5128C6.17205 11.5364 6.3781 11.554 6.57826 11.554C9.2157 11.554 11.1231 10.2059 12.9717 8.61634C13.2778 8.8636 14.5318 9.4582 14.7908 9.71723C13.0365 11.189 8.94489 12.3723 6.62536 12.3723C6.40165 12.3723 6.18971 12.3606 5.97777 12.337V13.5792H15.9977V3L5.966 3ZM5.966 9.88207V10.6356C3.52872 10.2 2.8517 7.66262 2.8517 7.66262C2.8517 7.66262 4.02324 6.36745 5.966 6.15551V6.97971H5.96011C4.94163 6.85608 4.14098 7.8098 4.14098 7.8098C4.14098 7.8098 4.59429 9.41699 5.966 9.88207ZM1.63895 7.55665C1.63895 7.55665 3.0813 5.4255 5.97189 5.20179V4.42469C2.76928 4.68372 0.00231934 7.39181 0.00231934 7.39181C0.00231934 7.39181 1.5683 11.9249 5.966 12.337V11.5128C2.73984 11.1125 1.63895 7.55665 1.63895 7.55665Z" fill="#76B900"/>
</g>
<defs>
<clipPath id="clip0_3572_51">
<rect width="16" height="10.5792" fill="white" transform="translate(0 3)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,14 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="8" fill="white"/>
<mask id="mask0_3571_174" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="1" y="2" width="13" height="12">
<path d="M13.5 2H1.5V14H13.5V2Z" fill="white"/>
</mask>
<g mask="url(#mask0_3571_174)">
<path d="M1.57031 7.83424C1.92188 7.83424 3.28125 7.53084 3.98438 7.1324C4.6875 6.73396 4.6875 6.73396 6.14062 5.70271C7.9804 4.39708 9.28125 4.83424 11.4141 4.83424" fill="black"/>
<path d="M1.57031 7.83424C1.92188 7.83424 3.28125 7.53084 3.98438 7.1324C4.6875 6.73396 4.6875 6.73396 6.14062 5.70271C7.9804 4.39708 9.28125 4.83424 11.4141 4.83424" stroke="black" stroke-width="2.10937"/>
<path d="M13.4766 4.84758L9.87305 6.92808V2.76709L13.4766 4.84758Z" fill="black" stroke="black" stroke-width="0.0234375"/>
<path d="M1.5 7.83594C1.85156 7.83594 3.21094 8.13934 3.91406 8.53777C4.61719 8.93621 4.61719 8.93621 6.07031 9.96746C7.91009 11.2731 9.21094 10.8359 11.3438 10.8359" fill="black"/>
<path d="M1.5 7.83594C1.85156 7.83594 3.21094 8.13934 3.91406 8.53777C4.61719 8.93621 4.61719 8.93621 6.07031 9.96746C7.91009 11.2731 9.21094 10.8359 11.3438 10.8359" stroke="black" stroke-width="2.10937"/>
<path d="M13.4062 10.8229L9.80273 8.74243V12.9034L13.4062 10.8229Z" fill="black" stroke="black" stroke-width="0.0234375"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 146 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.09026 8.41933L3.72077 7.62985C1.24061 6.80348 0 6.3903 0 5.63033C0 4.87142 1.24061 4.45718 3.72077 3.63081L12.6938 0.639442C14.4393 0.0576106 15.3121 -0.233305 15.7727 0.227312C16.2333 0.687928 15.9424 1.56068 15.3616 3.30512L12.3692 12.2792C11.5428 14.7594 11.1296 16 10.3697 16C9.61076 16 9.19652 14.7594 8.37015 12.2792L7.57962 9.9108L12.1689 5.3215C12.3609 5.1227 12.4672 4.85645 12.4648 4.58008C12.4624 4.30372 12.3515 4.03935 12.1561 3.84392C11.9607 3.64849 11.6963 3.53764 11.4199 3.53524C11.1435 3.53284 10.8773 3.63908 10.6785 3.83108L6.09026 8.41933Z" fill="#4377E9"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@ -0,0 +1,53 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
{
variants: {
variant: {
default: 'bg-primary text-primary-fg shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive-fg',
link: 'underline-offset-4 hover:underline',
},
size: {
default: 'h-7 px-3 py-2 has-[>svg]:px-3 rounded-sm',
sm: 'h-6 rounded gap-1.5 px-2 has-[>svg]:px-2.5',
lg: 'h-9 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }

View File

@ -0,0 +1,133 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-main-view/80 backdrop-blur-sm',
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-main-view max-h-[calc(100%-48px)] overflow-auto border-main-view-fg/10 text-main-view-fg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="data-[state=open]:text-main-view-fg/50 absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-0 focus:outline-0 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-medium', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-main-view-fg/80 text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,250 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
className="outline-none"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-main-view select-none text-main-view-fg border-main-view-fg/5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
className={cn(
"relative cursor-pointer hover:bg-main-view-fg/4 flex items-center gap-2 rounded-sm px-2 py-1 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm data-[inset]:pl-8', className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-main-view-fg/5', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'flex cursor-pointer hover:bg-main-view-fg/4 data-[state=open]:bg-main-view-fg/4 items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-main-view text-main-view-fg border-main-view-fg/5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,42 @@
import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import { cn } from '@/lib/utils'
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-main-view text-main-view-fg/70 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border border-main-view-fg/10 p-2 px-3 shadow-md outline-hidden',
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'placeholder:text-main-view-fg/40 border-main-view-fg/10 flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px] ring-main-view-fg/10',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
'[&::-webkit-inner-spin-button]:appearance-none',
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,46 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-main-view text-main-view-fg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-main-view-fg/10 p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,29 @@
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
'bg-accent/30 relative h-2 w-full overflow-hidden rounded-full',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-accent h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -0,0 +1,137 @@
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-main-view/50 backdrop-blur-xs transition-all duration-300 ease-in-out',
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-main-view text-sm text-main-view-fg data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-1 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l border-main-view-fg/4 sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r border-main-view-fg/4 sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b border-main-view-fg/4',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t border-main-view-fg/4',
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute top-4 text-main-view-fg right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-0 disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('font-medium', className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-main-view-fg/70 text-sm', className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,61 @@
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
'bg-main-view-fg/10 relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5'
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
'bg-accent absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-accent bg-main-view ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-2 ring-accent focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@ -0,0 +1,20 @@
import { Toaster as Sonner, ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
// theme={theme as ToasterProps['theme']}
className="toaster group"
// style={
// {
// '--normal-bg': 'var(--app-main-view)',
// '--normal-text': 'var(--popover-foreground)',
// '--normal-border': 'var(--border)',
// } as React.CSSProperties
// }
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,29 @@
import * as React from 'react'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'peer cursor-pointer data-[state=checked]:bg-accent data-[state=unchecked]:bg-main-view-fg/20 focus-visible:border-none inline-flex h-[18px] w-8.5 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-main-view pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-1px)] data-[state=unchecked]:-translate-x-0'
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@ -0,0 +1,19 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-main-view-fg/40 border-main-view-fg/10 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 ',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px] ring-main-view-fg/10',
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,59 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-main-view-fg text-main-view animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance capitalize',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-main-view-fg fill-main-view-fg z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,12 @@
export const localStoregeKey = {
LeftPanel: 'left-panel',
threads: 'threads',
messages: 'messages',
theme: 'theme',
modelProvider: 'model-provider',
settingAppearance: 'setting-appearance',
settingGeneral: 'setting-general',
settingCodeBlock: 'setting-code-block',
settingMCPSevers: 'setting-mcp-servers',
settingLocalApiServer: 'setting-local-api-server',
}

View File

@ -0,0 +1,19 @@
export const route = {
// home as new chat or thread
home: '/',
settings: {
index: '/settings',
providers: '/settings/providers/$providerName',
general: '/settings/general',
appearance: '/settings/appearance',
privacy: '/settings/privacy',
shortcuts: '/settings/shortcuts',
extensions: '/settings/extensions',
local_api_server: '/settings/local-api-server',
mcp_servers: '/settings/mcp-servers',
https_proxy: '/settings/https-proxy',
},
hub: '/hub',
localApiServerlogs: '/local-api-server/logs',
threadsDetail: '/threads/$threadId',
}

View File

@ -0,0 +1,34 @@
import { Input } from '@/components/ui/input'
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
import { useState } from 'react'
export function ApiPrefixInput() {
const { apiPrefix, setApiPrefix } = useLocalApiServer()
const [inputValue, setInputValue] = useState(apiPrefix)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
}
const handleBlur = () => {
// Ensure prefix starts with a slash
let prefix = inputValue.trim()
if (!prefix.startsWith('/')) {
prefix = '/' + prefix
}
setApiPrefix(prefix)
setInputValue(prefix)
}
return (
<Input
type="text"
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
className="w-24 h-8 text-sm"
placeholder="/v1"
/>
)
}

View File

@ -0,0 +1,70 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
IconEye,
IconTool,
IconAtom,
IconWorld,
IconCodeCircle2,
} from '@tabler/icons-react'
import { Fragment } from 'react/jsx-runtime'
interface CapabilitiesProps {
capabilities: string[]
}
const Capabilities = ({ capabilities }: CapabilitiesProps) => {
if (!capabilities.length) return null
return (
<div className="flex gap-1">
{capabilities.map((capability: string, capIndex: number) => {
let icon = null
if (capability === 'vision') {
icon = <IconEye className="size-4" />
} else if (capability === 'tools') {
icon = <IconTool className="size-3.5" />
} else if (capability === 'reasoning') {
icon = <IconAtom className="size-3.5" />
} else if (capability === 'embeddings') {
icon = <IconCodeCircle2 className="size-3.5" />
} else if (capability === 'web_search') {
icon = <IconWorld className="size-3.5" />
} else {
icon = null
}
return (
<Fragment key={`capability-${capIndex}`}>
{icon && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex items-center gap-1 size-5 bg-main-view-fg/5 rounded text-main-view-fg/50 justify-center last:mr-1 hover:text-main-view-fg transition-all"
title={capability}
>
{icon}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{capability === 'web_search' ? 'Web Search' : capability}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</Fragment>
)
})}
</div>
)
}
export default Capabilities

View File

@ -0,0 +1,65 @@
import { cn } from '@/lib/utils'
import { ReactNode } from 'react'
type CardProps = {
title?: string
children?: ReactNode
header?: ReactNode
}
type CardItemProps = {
title?: string | ReactNode
description?: string | ReactNode
align?: 'start' | 'center' | 'end'
actions?: ReactNode
column?: boolean
className?: string
}
export function CardItem({
title,
description,
className,
align = 'center',
column,
actions,
}: CardItemProps) {
return (
<div
className={cn(
'flex justify-between mt-2 first:mt-0 border-b border-main-view-fg/5 pb-3 last:border-none last:pb-0 gap-8',
className,
align === 'start' && 'items-start',
align === 'center' && 'items-center',
align === 'end' && 'items-end',
column && 'flex-col gap-y-0 items-start'
)}
>
<div className="space-y-1.5">
<h1 className="font-medium">{title}</h1>
{description && (
<span className="text-main-view-fg/70 leading-normal">
{description}
</span>
)}
</div>
{actions && (
<div className={cn('shrink-0', column && 'w-full')}>{actions}</div>
)}
</div>
)
}
export function Card({ title, children, header }: CardProps) {
return (
<div className="bg-main-view-fg/3 p-4 rounded-lg text-main-view-fg/90 w-full">
{title && (
<h1 className="text-main-view-fg font-medium text-base mb-4">
{title}
</h1>
)}
{header && header}
{children}
</div>
)
}

View File

@ -0,0 +1,353 @@
'use client'
import TextareaAutosize from 'react-textarea-autosize'
import { cn } from '@/lib/utils'
import { usePrompt } from '@/hooks/usePrompt'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { ArrowRight } from 'lucide-react'
import {
IconPaperclip,
IconWorld,
IconAtom,
IconMicrophone,
IconEye,
IconTool,
IconCodeCircle2,
IconPlayerStopFilled,
IconBrandSpeedtest,
} from '@tabler/icons-react'
import { useTranslation } from 'react-i18next'
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { useModelProvider } from '@/hooks/useModelProvider'
import {
emptyThreadContent,
newAssistantThreadContent,
newUserThreadContent,
sendCompletion,
startModel,
} from '@/lib/completion'
import { useThreads } from '@/hooks/useThreads'
import { defaultModel } from '@/lib/models'
import { useMessages } from '@/hooks/useMessages'
import { useRouter } from '@tanstack/react-router'
import { route } from '@/constants/routes'
import { useAppState } from '@/hooks/useAppState'
import { MovingBorder } from './MovingBorder'
import { MCPTool } from '@/types/completion'
import { listen } from '@tauri-apps/api/event'
import { SystemEvent } from '@/types/events'
type ChatInputProps = {
className?: string
showSpeedToken?: boolean
}
const ChatInput = ({ className, showSpeedToken = true }: ChatInputProps) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [isFocused, setIsFocused] = useState(false)
const [rows, setRows] = useState(1)
const [tools, setTools] = useState<MCPTool[]>([])
const { prompt, setPrompt } = usePrompt()
const { t } = useTranslation()
const { spellCheckChatInput } = useGeneralSetting()
const maxRows = 10
const { getProviderByName, selectedModel, selectedProvider } =
useModelProvider()
const { getCurrentThread: retrieveThread, createThread } = useThreads()
const { streamingContent, updateStreamingContent } = useAppState()
const { addMessage } = useMessages()
const router = useRouter()
const { updateLoadingModel } = useAppState()
const provider = useMemo(() => {
return getProviderByName(selectedProvider)
}, [selectedProvider, getProviderByName])
useEffect(() => {
const handleFocusIn = () => {
if (document.activeElement === textareaRef.current) {
setIsFocused(true)
}
}
const handleFocusOut = () => {
if (document.activeElement !== textareaRef.current) {
setIsFocused(false)
}
}
document.addEventListener('focusin', handleFocusIn)
document.addEventListener('focusout', handleFocusOut)
return () => {
document.removeEventListener('focusin', handleFocusIn)
document.removeEventListener('focusout', handleFocusOut)
}
}, [])
useEffect(() => {
window.core?.api?.getTools().then((data: MCPTool[]) => {
setTools(data)
})
let unsubscribe = () => {}
listen(SystemEvent.MCP_UPDATE, () => {
window.core?.api?.getTools().then((data: MCPTool[]) => {
setTools(data)
})
}).then((unsub) => {
// Unsubscribe from the event when the component unmounts
unsubscribe = unsub
})
return () => {
unsubscribe()
}
}, [])
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus()
}
}, [])
const getCurrentThread = useCallback(async () => {
let currentThread = retrieveThread()
if (!currentThread) {
currentThread = await createThread(
{
id: selectedModel?.id ?? defaultModel(selectedProvider),
provider: selectedProvider,
},
prompt
)
router.navigate({
to: route.threadsDetail,
params: { threadId: currentThread.id },
})
}
return currentThread
}, [
createThread,
prompt,
retrieveThread,
router,
selectedModel?.id,
selectedProvider,
])
const sendMessage = useCallback(async () => {
const activeThread = await getCurrentThread()
if (!activeThread || !provider) return
updateStreamingContent(emptyThreadContent)
addMessage(newUserThreadContent(activeThread.id, prompt))
setPrompt('')
try {
if (selectedModel?.id) {
updateLoadingModel(true)
await startModel(provider.provider, selectedModel.id).catch(() => {})
updateLoadingModel(false)
}
const completion = await sendCompletion(
activeThread,
provider,
prompt,
tools
)
if (!completion) throw new Error('No completion received')
let accumulatedText = ''
try {
for await (const part of completion) {
const delta = part.choices[0]?.delta?.content || ''
if (delta) {
accumulatedText += delta
// Create a new object each time to avoid reference issues
// Use a timeout to prevent React from batching updates too quickly
const currentContent = newAssistantThreadContent(
activeThread.id,
accumulatedText
)
updateStreamingContent(currentContent)
await new Promise((resolve) => setTimeout(resolve, 0))
}
}
} catch (error) {
console.error('Error during streaming:', error)
} finally {
// Create a final content object for adding to the thread
if (accumulatedText) {
const finalContent = newAssistantThreadContent(
activeThread.id,
accumulatedText
)
addMessage(finalContent)
}
}
} catch (error) {
console.error('Error sending message:', error)
}
updateStreamingContent(undefined)
}, [
getCurrentThread,
provider,
updateStreamingContent,
addMessage,
prompt,
setPrompt,
selectedModel,
tools,
updateLoadingModel,
])
return (
<div className="relative">
<div
className={cn(
'relative overflow-hidden p-[2px] rounded-lg',
Boolean(streamingContent) && 'opacity-70'
)}
>
{streamingContent && (
<div className="absolute inset-0">
<MovingBorder rx="10%" ry="10%">
<div
className={cn(
'h-100 w-100 bg-[radial-gradient(var(--app-primary),transparent_60%)]'
)}
/>
</MovingBorder>
</div>
)}
<div
className={cn(
'relative z-20 px-0 pb-10 border border-main-view-fg/5 rounded-lg text-main-view-fg bg-main-view',
isFocused && 'ring-1 ring-main-view-fg/10'
)}
>
<TextareaAutosize
ref={textareaRef}
disabled={Boolean(streamingContent)}
minRows={2}
rows={1}
maxRows={10}
value={prompt}
onChange={(e) => {
setPrompt(e.target.value)
// Count the number of newlines to estimate rows
const newRows = (e.target.value.match(/\n/g) || []).length + 1
setRows(Math.min(newRows, maxRows))
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && prompt) {
e.preventDefault()
// Submit the message when Enter is pressed without Shift
sendMessage()
// When Shift+Enter is pressed, a new line is added (default behavior)
}
}}
placeholder={t('common.placeholder.chatInput')}
autoFocus
spellCheck={spellCheckChatInput}
data-gramm={spellCheckChatInput}
data-gramm_editor={spellCheckChatInput}
data-gramm_grammarly={spellCheckChatInput}
className={cn(
'bg-transparent pt-4 w-full flex-shrink-0 border-none resize-none outline-0 px-4',
rows < maxRows && 'scrollbar-hide',
className
)}
/>
</div>
</div>
<div className="absolute z-20 bg-transparent bottom-0 w-full p-2 ">
<div className="flex justify-between items-center w-full">
<div className="px-1 flex items-center gap-1">
<div
className={cn(
'px-1 flex items-center gap-1',
streamingContent && 'opacity-50 pointer-events-none'
)}
>
{/* File attachment - always available */}
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
<IconPaperclip size={18} className="text-main-view-fg/50" />
</div>
{/* Microphone - always available */}
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
<IconMicrophone size={18} className="text-main-view-fg/50" />
</div>
{selectedModel?.capabilities?.includes('vision') && (
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
<IconEye size={18} className="text-main-view-fg/50" />
</div>
)}
{selectedModel?.capabilities?.includes('embeddings') && (
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
<IconCodeCircle2 size={18} className="text-main-view-fg/50" />
</div>
)}
{selectedModel?.capabilities?.includes('tools') && (
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
<IconTool size={18} className="text-main-view-fg/50" />
</div>
)}
{selectedModel?.capabilities?.includes('web_search') && (
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
<IconWorld size={18} className="text-main-view-fg/50" />
</div>
)}
{selectedModel?.capabilities?.includes('reasoning') && (
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
<IconAtom size={18} className="text-main-view-fg/50" />
</div>
)}
</div>
{showSpeedToken && (
<div className="flex items-center gap-1 text-main-view-fg/60 text-xs">
<IconBrandSpeedtest size={18} />
<span>42 tokens/sec</span>
</div>
)}
</div>
{streamingContent ? (
<Button variant="destructive" size="icon">
<IconPlayerStopFilled />
</Button>
) : (
<Button
variant={!prompt ? null : 'default'}
size="icon"
disabled={!prompt}
onClick={sendMessage}
>
{streamingContent ? (
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
) : (
<ArrowRight className="text-primary-fg" />
)}
</Button>
)}
</div>
</div>
</div>
)
}
export default ChatInput

View File

@ -0,0 +1,25 @@
import { RenderMarkdown } from './RenderMarkdown'
const EXAMPLE_CODE = `\`\`\`typescript
// Example code for preview
function greeting(name: string) {
return \`Hello, \${name}!\`;
}
// Call the function
const message = greeting('Jan');
console.log(message); // Outputs: Hello, Jan!
\`\`\``
export function CodeBlockExample() {
return (
<div className="w-full overflow-hidden border border-main-view-fg/10 rounded-md my-2">
<div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10">
<span className="font-medium text-xs font-sans">Preview</span>
</div>
<div className="overflow-auto p-2">
<RenderMarkdown content={EXAMPLE_CODE} />
</div>
</div>
)
}

View File

@ -0,0 +1,271 @@
// Available styles from react-syntax-highlighter/prism
// https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_STYLES_PRISM.MD
const CODE_BLOCK_STYLES = [
// Dark themes
'a11y-dark',
'atom-dark',
'darcula',
'dark',
'dracula',
'duotone-dark',
'gruvbox-dark',
'material-dark',
'material-oceanic',
'night-owl',
'nord',
'okaidia',
'one-dark',
'shades-of-purple',
'solarized-dark-atom',
'synthwave84',
'twilight',
'vsc-dark-plus',
'xonokai',
// Light themes
'coldark-cold',
'coy',
'coy-without-shadows',
'duotone-light',
'ghcolors',
'gruvbox-light',
'material-light',
'one-light',
'prism',
'solarizedlight',
'vs',
// Special themes
'cb',
'coldark-dark',
'duotone-earth',
'duotone-forest',
'duotone-sea',
'duotone-space',
'funky',
'holi-theme',
'hopscotch',
'lucario',
'pojoaque',
'tomorrow',
'z-touch',
]
import { useCodeblock } from '@/hooks/useCodeblock'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import { IconSearch } from '@tabler/icons-react'
import { Input } from '@/components/ui/input'
// Function to format style names to be more readable
function formatStyleName(style: string): string {
// Special cases for abbreviations and specific terms
const specialCases: Record<string, string> = {
a11y: 'Accessibility',
cb: 'CB',
vsc: 'VSCode',
vs: 'Visual Studio',
ghcolors: 'GitHub Colors',
}
// Direct mappings for compound names that need special formatting
const directMappings: Record<string, string> = {
'solarized-dark-atom': 'Solarized Dark (Atom)',
'solarizedlight': 'Solarized Light',
'coy-without-shadows': 'Coy (Without Shadows)',
'gruvbox-dark': 'Gruvbox Dark',
'gruvbox-light': 'Gruvbox Light',
'material-dark': 'Material Dark',
'material-light': 'Material Light',
'material-oceanic': 'Material Oceanic',
'night-owl': 'Night Owl',
'one-dark': 'One Dark',
'one-light': 'One Light',
'shades-of-purple': 'Shades of Purple',
'coldark-cold': 'Coldark Cold',
'coldark-dark': 'Coldark Dark',
'holi-theme': 'Holi Theme',
'synthwave84': 'Synthwave 84',
'vsc-dark-plus': 'VSCode Dark+',
'atom-dark': 'Atom Dark',
'duotone-dark': 'Duotone Dark',
'duotone-earth': 'Duotone Earth',
'duotone-forest': 'Duotone Forest',
'duotone-light': 'Duotone Light',
'duotone-sea': 'Duotone Sea',
'duotone-space': 'Duotone Space',
}
// Check for direct mappings first
if (directMappings[style]) {
return directMappings[style]
}
// Process other styles
return style
.split('-')
.map((part) => {
// Check for special cases
if (specialCases[part]) {
return specialCases[part]
}
// Handle duotone prefix (fallback for any not in directMappings)
if (part.startsWith('duotone')) {
return 'Duotone ' + part.replace('duotone', '')
}
// Capitalize first letter of each word
return part.charAt(0).toUpperCase() + part.slice(1)
})
.join(' ')
}
export default function CodeBlockStyleSwitcher() {
const { codeBlockStyle, setCodeBlockStyle } = useCodeblock()
const [searchQuery, setSearchQuery] = useState('')
const changeCodeBlockStyle = (style: string) => {
setCodeBlockStyle(style)
}
// Extract styles by category
const darkThemes = CODE_BLOCK_STYLES.slice(1, 20)
const lightThemes = CODE_BLOCK_STYLES.slice(22, 33)
const specialThemes = CODE_BLOCK_STYLES.slice(35)
// Filter styles based on search query
const filteredDarkThemes = darkThemes.filter((style) =>
formatStyleName(style).toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredLightThemes = lightThemes.filter((style) =>
formatStyleName(style).toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredSpecialThemes = specialThemes.filter((style) =>
formatStyleName(style).toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span
title="Edit Code Block Style"
className="flex cursor-pointer items-center gap-1 px-2 py-1 rounded-sm bg-main-view-fg/15 text-sm outline-none text-main-view-fg font-medium"
>
{formatStyleName(codeBlockStyle || 'one-light')}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-64 max-h-80 overflow-y-auto"
>
{/* Search input */}
<div className="px-2 py-2 sticky -top-1 bg-main-view z-10">
<div className="relative">
<IconSearch className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4" />
<Input
type="text"
placeholder="Search styles..."
value={searchQuery}
onClick={(e) => {
e.stopPropagation()
}}
onKeyDown={(e) => {
e.stopPropagation()
}}
onChange={(e) => {
e.preventDefault()
e.stopPropagation()
setSearchQuery(e.target.value)
}}
className="w-full pl-8 pr-2"
autoFocus
/>
</div>
</div>
{/* Dark themes */}
{filteredDarkThemes.length > 0 && (
<>
<DropdownMenuLabel className="font-medium text-xs px-2 pt-2 text-main-view-fg/60">
Dark Themes
</DropdownMenuLabel>
{filteredDarkThemes.map((style) => (
<DropdownMenuItem
key={style}
className={cn(
'cursor-pointer my-0.5',
codeBlockStyle === style && 'bg-main-view-fg/5'
)}
onClick={() => changeCodeBlockStyle(style)}
>
{formatStyleName(style)}
</DropdownMenuItem>
))}
</>
)}
{/* Light themes */}
{filteredLightThemes.length > 0 && (
<>
<DropdownMenuLabel className="font-medium text-xs px-2 pt-2 text-main-view-fg/60">
Light Themes
</DropdownMenuLabel>
{filteredLightThemes.map((style) => (
<DropdownMenuItem
key={style}
className={cn(
'cursor-pointer my-0.5',
codeBlockStyle === style && 'bg-main-view-fg/5'
)}
onClick={() => changeCodeBlockStyle(style)}
>
{formatStyleName(style)}
</DropdownMenuItem>
))}
</>
)}
{/* Special themes */}
{filteredSpecialThemes.length > 0 && (
<>
<DropdownMenuLabel className="font-medium text-xs px-2 pt-2 text-main-view-fg/60">
Special Themes
</DropdownMenuLabel>
{filteredSpecialThemes.map((style) => (
<DropdownMenuItem
key={style}
className={cn(
'cursor-pointer my-0.5',
codeBlockStyle === style && 'bg-main-view-fg/5'
)}
onClick={() => changeCodeBlockStyle(style)}
>
{formatStyleName(style)}
</DropdownMenuItem>
))}
</>
)}
{/* No results message */}
{filteredDarkThemes.length === 0 &&
filteredLightThemes.length === 0 &&
filteredSpecialThemes.length === 0 && (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
No styles found
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,90 @@
import { useAppearance } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function ColorPickerAppAccentColor() {
const { appAccentBgColor, setAppAccentBgColor } = useAppearance()
const predefineAppAccentBgColor: RgbaColor[] = [
{
r: 45,
g: 120,
b: 220,
a: 1,
},
{
r: 220,
g: 45,
b: 120,
a: 1,
},
{
r: 180,
g: 120,
b: 45,
a: 1,
},
]
return (
<div className="flex items-center gap-1.5">
{predefineAppAccentBgColor.map((item, i) => {
const isSelected =
item.r === appAccentBgColor.r &&
item.g === appAccentBgColor.g &&
item.b === appAccentBgColor.b &&
item.a === appAccentBgColor.a
return (
<div
key={i}
className={cn(
'size-4 rounded-full border border-main-view-fg/20',
isSelected && 'ring-2 ring-blue-500 border-none'
)}
onClick={() => {
setAppAccentBgColor(item)
}}
style={{
backgroundColor: `rgba(${item.r}, ${item.g}, ${item.b}, ${item.a})`,
}}
/>
)
})}
<div className="">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title="Pick Color App Accent"
className="size-6 cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10"
>
<IconColorPicker size={18} className="text-main-view-fg/50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="border-none w-full h-full overflow-visible"
side="right"
align="start"
style={{ zIndex: 9999 }}
>
<div>
<RgbaColorPicker
color={appAccentBgColor}
onChange={(color) => {
setAppAccentBgColor(color)
}}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@ -0,0 +1,103 @@
import { useAppearance } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function ColorPickerAppBgColor() {
const { appBgColor, setAppBgColor } = useAppearance()
const predefineAppBgColor: RgbaColor[] = [
{
r: 20,
g: 20,
b: 20,
a: 0.4,
},
{
r: 250,
g: 250,
b: 250,
a: 0.4,
},
{
r: 70,
g: 79,
b: 229,
a: 0.5,
},
{
r: 238,
g: 130,
b: 238,
a: 0.5,
},
{
r: 255,
g: 99,
b: 71,
a: 0.5,
},
{
r: 255,
g: 165,
b: 0,
a: 0.5,
},
]
return (
<div className="flex items-center gap-1.5">
{predefineAppBgColor.map((item, i) => {
const isSelected =
item.r === appBgColor.r &&
item.g === appBgColor.g &&
item.b === appBgColor.b &&
item.a === appBgColor.a
return (
<div
key={i}
className={cn(
'size-4 rounded-full border border-main-view-fg/20',
isSelected && 'ring-2 ring-blue-500 border-none'
)}
onClick={() => {
setAppBgColor(item)
}}
style={{
backgroundColor: `rgba(${item.r}, ${item.g}, ${item.b}, ${item.a})`,
}}
/>
)
})}
<div className="">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title="Pick Color Window Background"
className="size-6 cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10"
>
<IconColorPicker size={18} className="text-main-view-fg/50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="border-none w-full h-full overflow-visible"
side="right"
align="start"
>
<RgbaColorPicker
color={appBgColor}
onChange={(color) => setAppBgColor(color)}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@ -0,0 +1,95 @@
import { useAppearance } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function ColorPickerAppDestructiveColor() {
const { appDestructiveBgColor, setAppDestructiveBgColor } = useAppearance()
const predefineAppDestructiveBgColor: RgbaColor[] = [
{
r: 220,
g: 45,
b: 45,
a: 1,
},
{
r: 220,
g: 100,
b: 45,
a: 1,
},
{
r: 180,
g: 45,
b: 120,
a: 1,
},
{
r: 150,
g: 45,
b: 180,
a: 1,
},
]
return (
<div className="flex items-center gap-1.5">
{predefineAppDestructiveBgColor.map((item, i) => {
const isSelected =
item.r === appDestructiveBgColor.r &&
item.g === appDestructiveBgColor.g &&
item.b === appDestructiveBgColor.b &&
item.a === appDestructiveBgColor.a
return (
<div
key={i}
className={cn(
'size-4 rounded-full border border-main-view-fg/20',
isSelected && 'ring-2 ring-blue-500 border-none'
)}
onClick={() => {
setAppDestructiveBgColor(item)
}}
style={{
backgroundColor: `rgba(${item.r}, ${item.g}, ${item.b}, ${item.a})`,
}}
/>
)
})}
<div className="">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title="Pick Color App Destructive"
className="size-6 cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10"
>
<IconColorPicker size={18} className="text-main-view-fg/50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="border-none w-full h-full overflow-visible"
side="right"
align="start"
style={{ zIndex: 9999 }}
>
<div>
<RgbaColorPicker
color={appDestructiveBgColor}
onChange={(color) => {
setAppDestructiveBgColor(color)
}}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
import { useAppearance } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function ColorPickerAppMainView() {
const { appMainViewBgColor, setAppMainViewBgColor } = useAppearance()
const predefineAppMainViewBgColor: RgbaColor[] = [
{
r: 251,
g: 251,
b: 251,
a: 1,
},
{
r: 24,
g: 24,
b: 24,
a: 1,
},
]
return (
<div className="flex items-center gap-1.5">
{predefineAppMainViewBgColor.map((item, i) => {
const isSelected =
item.r === appMainViewBgColor.r &&
item.g === appMainViewBgColor.g &&
item.b === appMainViewBgColor.b &&
item.a === appMainViewBgColor.a
return (
<div
key={i}
className={cn(
'size-4 rounded-full border border-main-view-fg/20',
isSelected && 'ring-2 ring-blue-500 border-none'
)}
onClick={() => {
setAppMainViewBgColor(item)
}}
style={{
backgroundColor: `rgba(${item.r}, ${item.g}, ${item.b}, ${item.a})`,
}}
/>
)
})}
<div className="">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title="Pick Color App Main View"
className="size-6 cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10"
>
<IconColorPicker size={18} className="text-main-view-fg/50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="border-none w-full h-full overflow-visible"
side="right"
align="start"
>
<RgbaColorPicker
color={appMainViewBgColor}
onChange={(color) => setAppMainViewBgColor(color)}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@ -0,0 +1,95 @@
import { useAppearance } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function ColorPickerAppPrimaryColor() {
const { appPrimaryBgColor, setAppPrimaryBgColor } = useAppearance()
const predefineappPrimaryBgColor: RgbaColor[] = [
{
r: 219,
g: 88,
b: 44,
a: 1,
},
{
r: 120,
g: 44,
b: 220,
a: 1,
},
{
r: 219,
g: 167,
b: 44,
a: 1,
},
{
r: 46,
g: 158,
b: 57,
a: 1,
},
]
return (
<div className="flex items-center gap-1.5">
{predefineappPrimaryBgColor.map((item, i) => {
const isSelected =
item.r === appPrimaryBgColor.r &&
item.g === appPrimaryBgColor.g &&
item.b === appPrimaryBgColor.b &&
item.a === appPrimaryBgColor.a
return (
<div
key={i}
className={cn(
'size-4 rounded-full border border-main-view-fg/20',
isSelected && 'ring-2 ring-blue-500 border-none'
)}
onClick={() => {
setAppPrimaryBgColor(item)
}}
style={{
backgroundColor: `rgba(${item.r}, ${item.g}, ${item.b}, ${item.a})`,
}}
/>
)
})}
<div className="">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title="Pick Color App Primary"
className="size-6 cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10"
>
<IconColorPicker size={18} className="text-main-view-fg/50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="border-none w-full h-full overflow-visible"
side="right"
align="start"
style={{ zIndex: 9999 }}
>
<div>
<RgbaColorPicker
color={appPrimaryBgColor}
onChange={(color) => {
setAppPrimaryBgColor(color)
}}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@ -0,0 +1,87 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Progress } from '@/components/ui/progress'
import { IconPlayerPauseFilled, IconX } from '@tabler/icons-react'
export function DownloadManagement() {
return (
<Popover>
<PopoverTrigger>
<div className="bg-left-panel-fg/10 hover:bg-left-panel-fg/12 p-2 rounded-md my-1 relative border border-left-panel-fg/10 cursor-pointer text-left">
<div className="bg-primary font-bold size-5 rounded-full absolute -top-2 -right-1 flex items-center justify-center text-primary-fg">
2
</div>
<p className="text-left-panel-fg/80 font-medium">Downloads</p>
<div className="mt-2 flex items-center justify-between space-x-2">
<Progress value={20} />
<span className="text-xs font-medium text-main-view-fg/80 shrink-0">
20%
</span>
</div>
</div>
</PopoverTrigger>
<PopoverContent
side="right"
align="end"
className="p-0 overflow-hidden text-sm select-none"
sideOffset={6}
>
<div className="flex flex-col">
<div className="p-2 py-1.5 bg-main-view-fg/5 border-b border-main-view-fg/6">
<p className="text-xs text-main-view-fg/70">Downloading</p>
</div>
<div className="p-2 max-h-[300px] overflow-y-auto space-y-2">
<div className="bg-main-view-fg/4 rounded-md p-2">
<div className="flex items-center justify-between">
<p className="truncate text-main-view-fg/80">llama3.2:1b</p>
<div className="shrink-0 flex items-center space-x-0.5">
<IconPlayerPauseFilled
size={16}
className="text-main-view-fg/70 cursor-pointer"
title="Pause download"
/>
<IconX
size={16}
className="text-main-view-fg/70 cursor-pointer"
title="Cancel download"
/>
</div>
</div>
<Progress value={25} className="my-2" />
<p className="text-main-view-fg/60 text-xs">
1065.28 MB/4.13 GB (25%)
</p>
</div>
<div className="bg-main-view-fg/4 rounded-md p-2">
<div className="flex items-center justify-between">
<p className="truncate text-main-view-fg/80">
deepseek-r1:1.5b
</p>
<div className="shrink-0 flex items-center space-x-0.5">
<IconPlayerPauseFilled
size={16}
className="text-main-view-fg/70 cursor-pointer"
title="Pause download"
/>
<IconX
size={16}
className="text-main-view-fg/70 cursor-pointer"
title="Cancel download"
/>
</div>
</div>
<Progress value={80} className="my-2" />
<p className="text-main-view-fg/60 text-xs">
1065.28 MB/4.13 GB (80%)
</p>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,147 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useModelProvider } from '@/hooks/useModelProvider'
import { cn, getProviderLogo, getProviderTitle } from '@/lib/utils'
import { useEffect, useState } from 'react'
import Capabilities from './Capabilities'
import { IconSettings } from '@tabler/icons-react'
import { useNavigate } from '@tanstack/react-router'
import { route } from '@/constants/routes'
import { useThreads } from '@/hooks/useThreads'
type DropdownModelProviderProps = {
model?: {
id: string
provider: string
}
}
const DropdownModelProvider = ({ model }: DropdownModelProviderProps) => {
const { providers, selectModelProvider, selectedProvider, selectedModel } =
useModelProvider()
const [displayModel, setDisplayModel] = useState<string>('')
const { updateCurrentThreadModel } = useThreads()
const navigate = useNavigate()
// Initialize model provider only once
useEffect(() => {
// Auto select model when existing thread is passed
if (model) {
selectModelProvider(model?.provider as string, model?.id as string)
} else {
// default model, we should add from setting
selectModelProvider('llama.cpp', 'llama3.2:3b')
}
}, [model, selectModelProvider, updateCurrentThreadModel]) // Only run when threadData changes
// Update display model when selection changes
useEffect(() => {
if (selectedProvider && selectedModel) {
setDisplayModel(selectedModel.id)
} else {
setDisplayModel('Select a model')
}
}, [selectedProvider, selectedModel])
if (!providers.length) return null
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="bg-main-view-fg/5 hover:bg-main-view-fg/8 px-2 py-1 rounded font-medium cursor-pointer flex items-center gap-1.5">
<img
src={getProviderLogo(selectedProvider as string)}
alt={`${selectedProvider} - Logo`}
className="size-4"
/>
<span className="text-main-view-fg/80">{displayModel}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-60 max-h-[320px]"
side="bottom"
align="start"
>
<DropdownMenuGroup>
{providers.map((provider, index) => {
// Only show active providers
if (!provider.active) return null
return (
<div
className={cn(
'bg-main-view-fg/4 first:mt-0 rounded-sm my-1.5 first:mb-0 '
)}
key={`provider-${index}`}
>
<div className="flex items-center justify-between">
<DropdownMenuLabel className="flex items-center gap-1.5">
<img
src={getProviderLogo(provider.provider)}
alt={`${provider.provider} - Logo`}
className="size-4"
/>
<span className="capitalize truncate">
{getProviderTitle(provider.provider)}
</span>
</DropdownMenuLabel>
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out mr-2"
onClick={() =>
navigate({
to: route.settings.providers,
params: { providerName: provider.provider },
})
}
>
<IconSettings size={18} className="text-main-view-fg/50" />
</div>
</div>
{provider.models.map((model, modelIndex) => {
const capabilities = model.capabilities || []
return (
<DropdownMenuItem
className={cn(
'h-8 mx-1',
provider.provider !== 'llama.cpp' &&
!provider.api_key?.length &&
'hidden'
)}
key={`model-${modelIndex}`}
onClick={() => {
selectModelProvider(provider.provider, model.id)
updateCurrentThreadModel({
id: model.id,
provider: provider.provider,
})
}}
>
<div className="flex items-center gap-1.5 w-full">
<span className="truncate text-main-view-fg/70">
{model.id}
</span>
<div className="-mr-1.5">
<Capabilities capabilities={capabilities} />
</div>
</div>
</DropdownMenuItem>
)
})}
</div>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default DropdownModelProvider

View File

@ -0,0 +1,41 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { fontSizeOptions, useAppearance } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils'
export function FontSizeSwitcher() {
const { fontSize, setFontSize } = useAppearance()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span
title="Adjust Font Size"
className="flex cursor-pointer items-center gap-1 px-2 py-1 rounded-sm bg-main-view-fg/15 text-sm outline-none text-main-view-fg font-medium"
>
{fontSizeOptions.find(
(item: { value: string; label: string }) => item.value === fontSize
)?.label || 'Medium'}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-24">
{fontSizeOptions.map((item: { value: string; label: string }) => (
<DropdownMenuItem
key={item.value}
className={cn(
'cursor-pointer my-0.5',
fontSize === item.value && 'bg-main-view-fg/5'
)}
onClick={() => setFontSize(item.value as FontSize)}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,36 @@
import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils'
import { IconLayoutSidebar } from '@tabler/icons-react'
import { ReactNode } from '@tanstack/react-router'
import { platform } from '@tauri-apps/plugin-os'
type HeaderPageProps = {
children: ReactNode
}
const HeaderPage = ({ children }: HeaderPageProps) => {
const platformName = platform()
const { open, setLeftPanel } = useLeftPanel()
return (
<div
className={cn(
'h-10 border-b border-main-view-fg/5 pl-18 text-main-view-fg flex items-center shrink-0',
platformName === 'macos' && !open ? 'pl-18' : 'pl-4'
)}
>
<div className="flex items-center gap-2">
{!open && (
<button
className="size-5 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10"
onClick={() => setLeftPanel(!open)}
>
<IconLayoutSidebar size={18} className="text-main-view-fg" />
</button>
)}
{children}
</div>
</div>
)
}
export default HeaderPage

View File

@ -0,0 +1,55 @@
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
const LANGUAGES = [
{ value: 'en', label: 'English' },
{ value: 'id', label: 'Bahasa' },
{ value: 'vn', label: 'Tiếng Việt' },
]
export default function LanguageSwitcher() {
const { i18n } = useTranslation()
const { setCurrentLanguage, currentLanguage } = useGeneralSetting()
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng)
setCurrentLanguage(lng as Language)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span
title="Change Language"
className="flex cursor-pointer items-center gap-1 px-2 py-1 rounded-sm bg-main-view-fg/15 text-sm outline-none text-main-view-fg font-medium"
>
{LANGUAGES.find(
(lang: { value: string; label: string }) =>
lang.value === currentLanguage
)?.label || 'English'}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-24">
{LANGUAGES.map((lang) => (
<DropdownMenuItem
key={lang.value}
className={cn(
'cursor-pointer my-0.5',
currentLanguage === lang.value && 'bg-main-view-fg/5'
)}
onClick={() => changeLanguage(lang.value)}
>
{lang.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,315 @@
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils'
import {
IconLayoutSidebar,
IconDots,
IconCirclePlusFilled,
IconSettingsFilled,
IconTrash,
IconStar,
IconAppsFilled,
IconX,
IconSearch,
} from '@tabler/icons-react'
import { route } from '@/constants/routes'
import ThreadList from './ThreadList'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useThreads } from '@/hooks/useThreads'
import { useTranslation } from 'react-i18next'
import { useMemo, useState } from 'react'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { DownloadManagement } from './DownloadManegement'
const mainMenus = [
{
title: 'common.newChat',
icon: IconCirclePlusFilled,
route: route.home,
},
{
title: 'common.hub',
icon: IconAppsFilled,
route: route.hub,
},
]
const secondaryMenus = [
{
title: 'common.settings',
icon: IconSettingsFilled,
route: route.settings.general,
},
]
const LeftPanel = () => {
const { open, setLeftPanel } = useLeftPanel()
const { t } = useTranslation()
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState('')
const currentPath = useRouterState({
select: (state) => state.location.pathname,
})
const { deleteAllThreads, unstarAllThreads, getFilteredThreads, threads } =
useThreads()
const filteredThreads = useMemo(() => {
return getFilteredThreads(searchTerm)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getFilteredThreads, searchTerm, threads])
// Memoize categorized threads based on filteredThreads
const favoritedThreads = useMemo(() => {
return filteredThreads.filter((t) => t.isFavorite)
}, [filteredThreads])
const unFavoritedThreads = useMemo(() => {
return filteredThreads.filter((t) => !t.isFavorite)
}, [filteredThreads])
const [openDropdown, setOpenDropdown] = useState(false)
return (
<aside
className={cn(
'w-48 shrink-0 rounded-lg m-1.5 mr-0 text-left-panel-fg',
open ? 'block' : 'hidden'
)}
>
<div className="relative h-8">
<button
className="absolute top-1/2 right-0 -translate-y-1/2"
onClick={() => setLeftPanel(!open)}
>
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out">
<IconLayoutSidebar size={18} className="text-left-panel-fg" />
</div>
</button>
</div>
<div className="flex flex-col justify-between h-[calc(100%-32px)] mt-0">
<div className="flex flex-col justify-between h-full">
<div className="mt-2 mb-4 space-y-1">
{mainMenus.map((menu) => {
return (
<Link
key={menu.title}
to={menu.route}
className="flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded [&.active]:bg-left-panel-fg/10"
>
<menu.icon size={18} className="text-left-panel-fg/70" />
<span className="font-medium text-left-panel-fg/90">
{t(menu.title)}
</span>
</Link>
)
})}
</div>
<div className="relative mb-4 mx-1">
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
<input
type="text"
placeholder={t('common.search')}
className="w-full px-2 pl-7 py-1 bg-left-panel-fg/10 rounded text-left-panel-fg focus:outline-none focus:ring-1 focus:ring-left-panel-fg/10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-left-panel-fg/70 hover:text-left-panel-fg"
onClick={() => setSearchTerm('')}
>
<IconX size={14} />
</button>
)}
</div>
<div className="flex flex-col w-full h-full overflow-hidden">
<div className="h-full overflow-y-auto overflow-x-hidden">
{favoritedThreads.length > 0 && (
<>
<div className="flex items-center justify-between mb-2">
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold sticky top-0">
{t('common.favorites')}
</span>
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10">
<IconDots
size={18}
className="text-left-panel-fg/60"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
onClick={() => {
unstarAllThreads()
toast.success('All Threads Unfavorited', {
id: 'unfav-all-threads',
description:
'All threads have been removed from your favorites.',
})
}}
>
<IconStar size={16} />
<span>{t('common.unstarAll')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex flex-col mb-4">
<ThreadList
threads={favoritedThreads}
isFavoriteSection={true}
/>
{favoritedThreads.length === 0 && (
<p className="text-xs text-left-panel-fg/50 px-1 font-semibold">
{t('chat.status.empty', { ns: 'chat' })}
</p>
)}
</div>
</>
)}
{unFavoritedThreads.length > 0 && (
<div className="flex items-center justify-between mb-2">
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold">
{t('common.recents')}
</span>
<div className="relative">
<Dialog
onOpenChange={(open) => {
if (!open) setOpenDropdown(false)
}}
>
<DropdownMenu
open={openDropdown}
onOpenChange={(open) => setOpenDropdown(open)}
>
<DropdownMenuTrigger asChild>
<button
className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<IconDots
size={18}
className="text-left-panel-fg/60"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DialogTrigger asChild>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
<IconTrash size={16} />
<span>{t('common.deleteAll')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Thread</DialogTitle>
<DialogDescription>
Are you sure you want to delete this thread?
This action cannot be undone.
</DialogDescription>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Cancel
</Button>
</DialogClose>
<Button
variant="destructive"
size="sm"
onClick={() => {
deleteAllThreads()
toast.success('Delete All Thread', {
id: 'delete-thread',
description:
'All thread has been permanently deleted.',
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
Delete
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</DropdownMenuContent>
</DropdownMenu>
</Dialog>
</div>
</div>
)}
<div className="flex flex-col mb-4">
<ThreadList threads={unFavoritedThreads} />
</div>
</div>
</div>
<div className="space-y-1 py-1 mt-2">
{secondaryMenus.map((menu) => {
const isActive =
currentPath.includes(route.settings.index) &&
menu.route.includes(route.settings.index)
return (
<Link
key={menu.title}
to={menu.route}
className={cn(
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
isActive
? 'bg-left-panel-fg/10'
: '[&.active]:bg-left-panel-fg/10'
)}
>
<menu.icon size={18} className="text-left-panel-fg/70" />
<span className="font-medium text-left-panel-fg/90">
{t(menu.title)}
</span>
</Link>
)
})}
</div>
<DownloadManagement />
</div>
</div>
</aside>
)
}
export default LeftPanel

View File

@ -0,0 +1,14 @@
import { useCodeblock } from '@/hooks/useCodeblock'
import { Switch } from '@/components/ui/switch'
export function LineNumbersSwitcher() {
const { showLineNumbers, setShowLineNumbers } = useCodeblock()
const toggleLineNumbers = () => {
setShowLineNumbers(!showLineNumbers)
}
return (
<Switch checked={showLineNumbers} onCheckedChange={toggleLineNumbers} />
)
}

View File

@ -0,0 +1,152 @@
import { IconSettings } from '@tabler/icons-react'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting'
import { useModelProvider } from '@/hooks/useModelProvider'
import { updateModel } from '@/services/models'
import { ModelSettingParams } from '@janhq/core'
// import {
// HoverCard,
// HoverCardContent,
// HoverCardTrigger,
// } from '@/components/ui/hover-card'
type ModelSettingProps = {
provider: ProviderObject
model: Model
}
export function ModelSetting({ model, provider }: ModelSettingProps) {
const { updateProvider } = useModelProvider()
const handleSettingChange = (
key: string,
value: string | boolean | number
) => {
if (!provider) return
// Create a copy of the model with updated settings
const updatedModel = {
...model,
settings: {
...model.settings,
[key]: {
...(model.settings?.[key] != null ? model.settings?.[key] : {}),
controller_props: {
...(model.settings?.[key]?.controller_props ?? {}),
value: value,
},
},
},
}
// Find the model index in the provider's models array
const modelIndex = provider.models.findIndex((m) => m.id === model.id)
if (modelIndex !== -1) {
// Create a copy of the provider's models array
const updatedModels = [...provider.models]
// Update the specific model in the array
updatedModels[modelIndex] = updatedModel as Model
// Update the provider with the new models array
updateProvider(provider.provider, {
models: updatedModels,
})
updateModel({
id: model.id,
settings: Object.entries(updatedModel.settings).map(([key, value]) => ({
[key]: value.controller_props?.value,
})) as ModelSettingParams,
})
}
}
return (
<Sheet>
<SheetTrigger asChild>
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out">
<IconSettings size={18} className="text-main-view-fg/50" />
</div>
</SheetTrigger>
<SheetContent className="h-[calc(100%-8px)] top-1 right-1 rounded-e-md overflow-y-auto">
<SheetHeader>
<SheetTitle>Model Setting {model.id}</SheetTitle>
<SheetDescription>
Configure model settings to optimize performance and behavior.
</SheetDescription>
</SheetHeader>
<div className="px-4 space-y-6">
{Object.entries(model.settings || {}).map(([key, value]) => {
const config = value as ProviderSetting
return (
<div key={key} className="space-y-2">
<div className="flex flex-col">
<div className="space-y-1 mb-2">
<h3 className="font-medium">{config.title}</h3>
<p className="text-main-view-fg/60 text-xs">
{config.description}
</p>
</div>
<DynamicControllerSetting
key={config.key}
title={config.title}
description={config.description}
controllerType={config.controller_type}
controllerProps={{
...config.controller_props,
value: config.controller_props?.value,
}}
onChange={(newValue) => handleSettingChange(key, newValue)}
/>
{/* <div className="mt-2">
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor={config.key}>{config.title}</label>
</div>
<DynamicControllerSetting
key={config.key}
title={config.title}
description={config.description}
controllerType={config.controller_type}
controllerProps={{
...config.controller_props,
value: value,
}}
onChange={(newValue) =>
handleSettingChange(key, newValue)
}
/>
</div>
</HoverCardTrigger>
<HoverCardContent
align="start"
className="w-[260px] text-sm"
side="left"
sideOffset={24}
>
{config.description}
</HoverCardContent>
</HoverCard>
</div> */}
</div>
</div>
)
})}
</div>
</SheetContent>
</Sheet>
)
}

View File

@ -0,0 +1,76 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useRef } from 'react'
import {
motion,
useAnimationFrame,
useMotionTemplate,
useMotionValue,
useTransform,
} from 'motion/react'
export const MovingBorder = ({
children,
duration = 3000,
rx,
ry,
}: {
children: React.ReactNode
duration?: number
rx?: string
ry?: string
[key: string]: any
}) => {
const pathRef = useRef<any>(null)
const progress = useMotionValue<number>(0)
useAnimationFrame((time) => {
const length = pathRef.current?.getTotalLength()
if (length) {
const pxPerMillisecond = length / duration
progress.set((time * pxPerMillisecond) % length)
}
})
const x = useTransform(
progress,
(val) => pathRef.current?.getPointAtLength(val).x
)
const y = useTransform(
progress,
(val) => pathRef.current?.getPointAtLength(val).y
)
const transform = useMotionTemplate`translateX(${x}px) translateY(${y}px) translateX(-50%) translateY(-50%)`
return (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
className="absolute h-full w-full"
width="100%"
height="100%"
>
<rect
fill="none"
width="100%"
height="100%"
rx={rx}
ry={ry}
ref={pathRef}
/>
</svg>
<motion.div
style={{
position: 'absolute',
top: 0,
left: 0,
display: 'inline-block',
transform,
}}
>
{children}
</motion.div>
</>
)
}

View File

@ -0,0 +1,14 @@
import { useMemo } from 'react'
// Detect if the user is on macOS
const isMac =
typeof navigator !== 'undefined' &&
navigator.userAgent.toUpperCase().indexOf('MAC') >= 0
export function PlatformMetaKey() {
const metaKeySymbol = useMemo(() => {
return isMac ? '⌘' : 'Ctrl'
}, [])
return <>{metaKeySymbol}</>
}

View File

@ -0,0 +1,35 @@
import { Input } from '@/components/ui/input'
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
import { useState } from 'react'
export function PortInput() {
const { serverPort, setServerPort } = useLocalApiServer()
const [inputValue, setInputValue] = useState(serverPort.toString())
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
}
const handleBlur = () => {
const port = parseInt(inputValue)
if (!isNaN(port) && port >= 0 && port <= 65535) {
setServerPort(port)
} else {
// Reset to current value if invalid
setInputValue(serverPort.toString())
}
}
return (
<Input
type="number"
min={0}
max={65535}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
className="w-24 h-8 text-sm"
/>
)
}

View File

@ -0,0 +1,58 @@
import { route } from '@/constants/routes'
import { useModelProvider } from '@/hooks/useModelProvider'
import { cn, getProviderLogo, getProviderTitle } from '@/lib/utils'
import { useNavigate, useMatches, Link } from '@tanstack/react-router'
import { IconArrowLeft } from '@tabler/icons-react'
const ProvidersMenu = () => {
const { providers } = useModelProvider()
const navigate = useNavigate()
const matches = useMatches()
return (
<div className="w-44 py-2 border-r border-main-view-fg/5 pb-10 overflow-y-auto">
<Link to={route.settings.general}>
<div className="flex items-center gap-0.5 ml-3 mb-4 mt-1">
<IconArrowLeft size={16} className="text-main-view-fg/70" />
<span className="text-main-view-fg/80">Back</span>
</div>
</Link>
{providers.map((provider, index) => {
const isActive = matches.some(
(match) =>
match.routeId === '/settings/providers/$providerName' &&
'providerName' in match.params &&
match.params.providerName === provider.provider
)
return (
<div key={index} className="flex flex-col px-2 my-1.5 ">
<div
className={cn(
'flex px-2 items-center gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5 text-main-view-fg/80',
isActive && 'bg-main-view-fg/5'
)}
onClick={() =>
navigate({
to: route.settings.providers,
params: { providerName: provider.provider },
})
}
>
<img
src={getProviderLogo(provider.provider)}
alt={`${provider.provider} - Logo`}
className="size-4"
/>
<span className="capitalize">
{getProviderTitle(provider.provider)}
</span>
</div>
</div>
)
})}
</div>
)
}
export default ProvidersMenu

View File

@ -0,0 +1,173 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
import ReactMarkdown, { Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkEmoji from 'remark-emoji'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import * as prismStyles from 'react-syntax-highlighter/dist/cjs/styles/prism'
import { memo, useState, useMemo } from 'react'
import virtualizedRenderer from 'react-syntax-highlighter-virtualized-renderer'
import { getReadableLanguageName } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { useCodeblock } from '@/hooks/useCodeblock'
import 'katex/dist/katex.min.css'
import { IconCopy, IconCopyCheck } from '@tabler/icons-react'
interface MarkdownProps {
content: string
className?: string
components?: Components
}
function RenderMarkdownComponent({
content,
className,
components,
}: MarkdownProps) {
const { codeBlockStyle, showLineNumbers } = useCodeblock()
// State for tracking which code block has been copied
const [copiedId, setCopiedId] = useState<string | null>(null)
// Function to handle copying code to clipboard
const handleCopy = (code: string, id: string) => {
navigator.clipboard.writeText(code)
setCopiedId(id)
// Reset copied state after 2 seconds
setTimeout(() => {
setCopiedId(null)
}, 2000)
}
// Simple hash function for strings
const hashString = (str: string): string => {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32bit integer
}
return Math.abs(hash).toString(36)
}
// Default components for syntax highlighting and emoji rendering
const defaultComponents: Components = useMemo(
() => ({
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const language = match ? match[1] : ''
const isInline = !match || !language
const code = String(children).replace(/\n$/, '')
// Generate a stable ID based on code content and language
const codeId = `code-${hashString(code.substring(0, 40) + language)}`
const shouldVirtualize = code.split('\n').length > 300
return !isInline ? (
<div className="relative overflow-hidden border rounded-md border-main-view-fg/2">
<div className="flex items-center justify-between px-4 py-2 bg-main-view/10">
<span className="font-medium text-xs font-sans">
{getReadableLanguageName(language)}
</span>
<button
onClick={() => handleCopy(code, codeId)}
className="flex items-center gap-1 text-xs font-sans transition-colors cursor-pointer"
>
{copiedId === codeId ? (
<>
<IconCopyCheck size={16} className="text-primary" />
<span>Copied!</span>
</>
) : (
<>
<IconCopy size={16} />
<span>Copy</span>
</>
)}
</button>
</div>
<SyntaxHighlighter
// @ts-expect-error - Type issues with style prop in react-syntax-highlighter
style={
prismStyles[
codeBlockStyle
.split('-')
.map((part: string, index: number) =>
index === 0
? part
: part.charAt(0).toUpperCase() + part.slice(1)
)
.join('') as keyof typeof prismStyles
] || prismStyles.oneLight
}
language={language}
showLineNumbers={showLineNumbers}
customStyle={{
margin: 0,
padding: '8px',
borderRadius: '0 0 4px 4px',
overflow: 'auto',
border: 'none',
}}
renderer={
shouldVirtualize
? (virtualizedRenderer() as (props: any) => React.ReactNode)
: undefined
}
PreTag="div"
CodeTag={'code'}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
)
},
}),
[codeBlockStyle, showLineNumbers, copiedId, handleCopy, hashString]
)
// Memoize the remarkPlugins to prevent unnecessary re-renders
const remarkPlugins = useMemo(() => {
// Using a simpler configuration to avoid TypeScript errors
return [remarkGfm, remarkMath, remarkEmoji]
}, [])
// Memoize the rehypePlugins to prevent unnecessary re-renders
const rehypePlugins = useMemo(() => [rehypeKatex], [])
// Merge custom components with default components
const mergedComponents = useMemo(
() => ({
...defaultComponents,
...components,
}),
[defaultComponents, components]
)
// Render the markdown content
return (
<div className={cn('markdown select-text', className)}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={mergedComponents}
>
{content}
</ReactMarkdown>
</div>
)
}
// Use a simple memo without custom comparison to allow re-renders when content changes
// This is important for streaming content to render incrementally
export const RenderMarkdown = memo(RenderMarkdownComponent)

View File

@ -0,0 +1,44 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
import { cn } from '@/lib/utils'
const hostOptions = [
{ value: '127.0.0.1', label: '127.0.0.1' },
{ value: '0.0.0.0', label: '0.0.0.0' },
]
export function ServerHostSwitcher() {
const { serverHost, setServerHost } = useLocalApiServer()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span
title="Edit Server Host"
className="flex cursor-pointer items-center gap-1 px-2 py-1 rounded-sm bg-main-view-fg/15 text-sm outline-none text-main-view-fg font-medium"
>
{serverHost}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-24">
{hostOptions.map((item) => (
<DropdownMenuItem
key={item.value}
className={cn(
'cursor-pointer my-0.5',
serverHost === item.value && 'bg-main-view-fg/5'
)}
onClick={() => setServerHost(item.value as '127.0.0.1' | '0.0.0.0')}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,102 @@
import { Link, useMatches } from '@tanstack/react-router'
import { route } from '@/constants/routes'
import { useTranslation } from 'react-i18next'
import { useModelProvider } from '@/hooks/useModelProvider'
const menuSettings = [
{
title: 'common.general',
route: route.settings.general,
},
{
title: 'common.appearance',
route: route.settings.appearance,
},
{
title: 'common.privacy',
route: route.settings.privacy,
},
{
title: 'common.keyboardShortcuts',
route: route.settings.shortcuts,
},
{
title: 'MCP Servers',
route: route.settings.mcp_servers,
},
{
title: 'Local API Server',
route: route.settings.local_api_server,
},
{
title: 'HTTPS Proxy',
route: route.settings.https_proxy,
},
{
title: 'Extensions',
route: route.settings.extensions,
},
]
const SettingsMenu = () => {
const { t } = useTranslation()
const { providers } = useModelProvider()
const firstItemProvider =
providers.length > 0 ? providers[0].provider : 'llama.cpp'
const matches = useMatches()
const isActive = matches.some(
(match) =>
match.routeId === '/settings/providers/$providerName' &&
'providerName' in match.params
)
return (
<div className="flex h-full w-44 shrink-0 px-1.5 pt-3 border-r border-main-view-fg/5">
<div className="flex flex-col gap-1 w-full text-main-view-fg/90 font-medium">
{menuSettings.map((menu, index) => {
// Render the menu item
const menuItem = (
<Link
key={menu.title}
to={menu.route}
className="block px-2 gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5"
>
<span className="text-main-view-fg/80">{t(menu.title)}</span>
</Link>
)
if (index === 2) {
return (
<div key={menu.title}>
<span className="mb-1 block">{menuItem}</span>
{/* Model Providers Link with default parameter */}
{isActive ? (
<div className="block px-2 mt-1 gap-1.5 py-1 w-full rounded bg-main-view-fg/5 cursor-pointer">
<span>{t('common.modelProviders')}</span>
</div>
) : (
<Link
key="common.modelProviders"
to={route.settings.providers}
params={{ providerName: firstItemProvider }}
className="block px-2 gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded"
>
<span className="text-main-view-fg/80">
{t('common.modelProviders')}
</span>
</Link>
)}
</div>
)
}
// For other menu items, just render them normally
return menuItem
})}
</div>
</div>
)
}
export default SettingsMenu

View File

@ -0,0 +1,14 @@
import { useAppState } from '@/hooks/useAppState'
import { ThreadContent } from './ThreadContent'
import { memo } from 'react'
// Use memo with no dependencies to allow re-renders when props change
export const StreamingContent = memo(() => {
const { streamingContent } = useAppState()
if (!streamingContent) return null
// Pass a new object to ThreadContent to avoid reference issues
// The streaming content is always the last message
return <ThreadContent {...streamingContent} isLastMessage={true} />
})

View File

@ -0,0 +1,46 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTheme } from '@/hooks/useTheme'
import { cn } from '@/lib/utils'
export function ThemeSwitcher() {
const themeOptions = [
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
{ value: 'auto', label: 'System' },
]
const { setTheme, activeTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span
title="Edit Theme"
className="flex cursor-pointer items-center gap-1 px-2 py-1 rounded-sm bg-main-view-fg/15 text-sm outline-none text-main-view-fg font-medium"
>
{themeOptions.find((item) => item.value === activeTheme)?.label ||
'Auto'}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-24">
{themeOptions.map((item) => (
<DropdownMenuItem
key={item.value}
className={cn(
'cursor-pointer my-0.5',
activeTheme === item.value && 'bg-main-view-fg/5'
)}
onClick={() => setTheme(item.value as 'auto' | 'light' | 'dark')}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,64 @@
import { ChevronDown, ChevronUp, Loader } from 'lucide-react'
import { create } from 'zustand'
import { RenderMarkdown } from './RenderMarkdown'
interface Props {
text: string
id: number
}
// Zustand store for thinking block state
type ThinkingBlockState = {
thinkingState: { [id: number]: boolean }
toggleState: (id: number) => void
}
const useThinkingStore = create<ThinkingBlockState>((set) => ({
thinkingState: {},
toggleState: (id) =>
set((state) => ({
thinkingState: {
...state.thinkingState,
[id]: !state.thinkingState[id],
},
})),
}))
const ThinkingBlock = ({ id, text }: Props) => {
const { thinkingState, toggleState } = useThinkingStore()
const loading = !text.includes('</think>')
const isExpanded = thinkingState[id] ?? false
const handleClick = () => toggleState(id)
if (!text.replace(/<\/?think>/g, '').trim()) return null
return (
<div className="mx-auto w-full cursor-pointer" onClick={handleClick}>
<div className="mb-4 rounded-lg bg-main-view-fg/4 border border-dashed border-main-view-fg/10 p-2">
<div className="flex items-center gap-3">
{loading && (
<Loader className="size-4 animate-spin text-main-view-fg/60" />
)}
<button className="flex items-center gap-2 focus:outline-none">
{isExpanded ? (
<ChevronUp className="size-4 text-main-view-fg/60" />
) : (
<ChevronDown className="size-4 text-main-view-fg/60" />
)}
<span className="font-medium">
{loading ? 'Thinking...' : 'Thought'}
</span>
</button>
</div>
{isExpanded && (
<div className="mt-2 pl-6 text-main-view-fg/60">
<RenderMarkdown content={text.replace(/<\/?think>/g, '').trim()} />
</div>
)}
</div>
</div>
)
}
export default ThinkingBlock

View File

@ -0,0 +1,177 @@
import { ThreadMessage } from '@janhq/core'
import { RenderMarkdown } from './RenderMarkdown'
import { Fragment, memo, useMemo, useState } from 'react'
import {
IconCopy,
IconCopyCheck,
IconRefresh,
IconTrash,
IconPencil,
} from '@tabler/icons-react'
import { useAppState } from '@/hooks/useAppState'
import ThinkingBlock from './ThinkingBlock'
import { cn } from '@/lib/utils'
import { useMessages } from '@/hooks/useMessages'
const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
className="flex items-center gap-1 hover:text-accent transition-colors group relative"
onClick={handleCopy}
>
{copied ? (
<>
<IconCopyCheck size={16} className="text-accent" />
<span className="opacity-100">Copied!</span>
</>
) : (
<>
<IconCopy size={16} />
<span className="opacity-0 w-0 overflow-hidden whitespace-nowrap group-hover:w-auto group-hover:opacity-100 transition-all duration-300 ease-in-out">
Copy
</span>
</>
)}
</button>
)
}
// Use memo to prevent unnecessary re-renders, but allow re-renders when props change
export const ThreadContent = memo(
(item: ThreadMessage & { isLastMessage?: boolean; index?: number }) => {
// Use useMemo to stabilize the components prop
const linkComponents = useMemo(
() => ({
a: ({ ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}),
[]
)
const image = useMemo(() => item.content?.[0]?.image_url, [item])
const { streamingContent } = useAppState()
const text = useMemo(
() => item.content.find((e) => e.type === 'text')?.text?.value ?? '',
[item.content]
)
const { reasoningSegment, textSegment } = useMemo(() => {
const isThinking = text.includes('<think>') && !text.includes('</think>')
if (isThinking) return { reasoningSegment: text, textSegment: '' }
const match = text.match(/<think>([\s\S]*?)<\/think>/)
if (match?.index === undefined)
return { reasoningSegment: undefined, textSegment: text }
const splitIndex = match.index + match[0].length
return {
reasoningSegment: text.slice(0, splitIndex),
textSegment: text.slice(splitIndex),
}
}, [text])
const { deleteMessage } = useMessages()
return (
<Fragment>
{item.content?.[0]?.text && item.role === 'user' && (
<div>
<div className="flex justify-end w-full">
<div className="bg-accent text-accent-fg p-2 rounded-md inline-block">
<p className="select-text">{item.content?.[0].text.value}</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2">
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
console.log('Edit clicked')
}}
>
<IconPencil size={16} />
<span className="opacity-0 w-0 overflow-hidden whitespace-nowrap group-hover:w-auto group-hover:opacity-100 transition-all duration-300 ease-in-out">
Edit
</span>
</button>
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
deleteMessage(item.thread_id, item.id)
}}
>
<IconTrash size={16} />
<span className="opacity-0 w-0 overflow-hidden whitespace-nowrap group-hover:w-auto group-hover:opacity-100 transition-all duration-300 ease-in-out">
Delete
</span>
</button>
</div>
</div>
)}
{item.content?.[0]?.text && item.role !== 'user' && (
<>
{reasoningSegment && (
<ThinkingBlock
id={item.index ?? Number(item.id)}
text={reasoningSegment}
/>
)}
<RenderMarkdown content={textSegment} components={linkComponents} />
<div className="flex items-center gap-2 mt-2 text-main-view-fg/60 text-xs">
<div
className={cn(
'flex items-center gap-2',
item.isLastMessage &&
streamingContent &&
'opacity-0 visinility-hidden pointer-events-none'
)}
>
<CopyButton text={item.content?.[0]?.text.value || ''} />
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
deleteMessage(item.thread_id, item.id)
}}
>
<IconTrash size={16} />
<span className="opacity-0 w-0 overflow-hidden whitespace-nowrap group-hover:w-auto group-hover:opacity-100 transition-all duration-300 ease-in-out">
Delete
</span>
</button>
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
console.log('Regenerate clicked')
}}
>
<IconRefresh size={16} />
<span className="opacity-0 w-0 overflow-hidden whitespace-nowrap group-hover:w-auto group-hover:opacity-100 transition-all duration-300 ease-in-out">
Regenerate
</span>
</button>
</div>
</div>
</>
)}
{item.type === 'image_url' && image && (
<div>
<img
src={image.url}
alt={image.detail || 'Thread image'}
className="max-w-full rounded-md"
/>
{image.detail && <p className="text-sm mt-1">{image.detail}</p>}
</div>
)}
</Fragment>
)
}
)

View File

@ -0,0 +1,324 @@
import {
DndContext,
closestCenter,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
} from '@dnd-kit/core'
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
IconDots,
IconStarFilled,
IconTrash,
IconEdit,
IconStar,
} from '@tabler/icons-react'
import { useThreads } from '@/hooks/useThreads'
import { cn } from '@/lib/utils'
import { route } from '@/constants/routes'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { DialogClose, DialogFooter, DialogHeader } from '@/components/ui/dialog'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { memo, useMemo, useState } from 'react'
import { useNavigate, useMatches } from '@tanstack/react-router'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
const SortableItem = memo(({ thread }: { thread: Thread }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: thread.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const { toggleFavorite, deleteThread, renameThread } = useThreads()
const { t } = useTranslation()
const [openDropdown, setOpenDropdown] = useState(false)
const navigate = useNavigate()
// Check if current route matches this thread's detail page
const matches = useMatches()
const isActive = matches.some(
(match) =>
match.routeId === '/threads/$threadId' &&
'threadId' in match.params &&
match.params.threadId === thread.id
)
const handleClick = () => {
if (!isDragging) {
navigate({ to: route.threadsDetail, params: { threadId: thread.id } })
}
}
const [title, setTitle] = useState(thread.title || 'New Thread')
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
className={cn(
'mb-1 rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all',
isDragging ? 'cursor-move' : 'cursor-pointer',
isActive && 'bg-left-panel-fg/10'
)}
>
<div className="py-1 pr-2 truncate">
<span className="text-left-panel-fg/90">
{thread.title || 'New Thread'}
</span>
</div>
<div className="flex items-center">
<DropdownMenu
open={openDropdown}
onOpenChange={(open) => setOpenDropdown(open)}
>
<DropdownMenuTrigger asChild>
<IconDots
size={14}
className="text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded group-hover/thread-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
{thread.isFavorite ? (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
toggleFavorite(thread.id)
}}
>
<IconStarFilled />
<span>{t('common.unstar')}</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
toggleFavorite(thread.id)
}}
>
<IconStar />
<span>{t('common.star')}</span>
</DropdownMenuItem>
)}
<Dialog
onOpenChange={(open) => {
if (!open) {
setOpenDropdown(false)
setTitle(thread.title)
}
}}
>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconEdit />
<span>{t('common.rename')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Title</DialogTitle>
<Input
value={title}
onChange={(e) => {
setTitle(e.target.value)
}}
className="mt-2"
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Cancel
</Button>
</DialogClose>
<Button
disabled={!title}
onClick={() => {
renameThread(thread.id, title)
setOpenDropdown(false)
toast.success('Renema Title', {
id: 'rename-thread',
description:
"Thread title has been renamed to '" + title + "'",
})
}}
>
Rename
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
<DropdownMenuSeparator />
<Dialog
onOpenChange={(open) => {
if (!open) setOpenDropdown(false)
}}
>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconTrash />
<span>{t('common.delete')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Thread</DialogTitle>
<DialogDescription>
Are you sure you want to delete this thread? This action
cannot be undone.
</DialogDescription>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Cancel
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
deleteThread(thread.id)
setOpenDropdown(false)
toast.success('Delete Thread', {
id: 'delete-thread',
description:
'This thread has been permanently deleted.',
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
Delete
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
})
type ThreadListProps = {
threads: Thread[]
isFavoriteSection?: boolean
}
function ThreadList({ threads, isFavoriteSection = false }: ThreadListProps) {
const { setThreads } = useThreads()
const sortedThreads = useMemo(() => {
return threads.sort((a, b) => {
if (a.order && b.order) return a.order - b.order
// Later on top
return (b.updated || 0) - (a.updated || 0)
})
}, [threads])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 200,
tolerance: 5,
},
}),
useSensor(KeyboardSensor)
)
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = threads.findIndex((t) => t.id === active.id)
const newIndex = threads.findIndex((t) => t.id === over?.id)
// Create a new array with the reordered threads from this section only
const reorderedSectionThreads = arrayMove(threads, oldIndex, newIndex)
// Split all threads into favorites and non-favorites
const favThreads = sortedThreads.filter((t) => t.isFavorite)
const nonFavThreads = sortedThreads.filter((t) => !t.isFavorite)
// Replace the appropriate section with the reordered threads
let updatedThreads
if (isFavoriteSection) {
// If we're in the favorites section, update favorites and keep non-favorites as is
updatedThreads = [...reorderedSectionThreads, ...nonFavThreads]
} else {
// If we're in the non-favorites section, update non-favorites and keep favorites as is
updatedThreads = [...favThreads, ...reorderedSectionThreads]
}
setThreads(updatedThreads)
}
}}
>
<SortableContext
items={sortedThreads.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
{sortedThreads.map((thread, index) => (
<SortableItem key={index} thread={thread} />
))}
</SortableContext>
</DndContext>
)
}
export default memo(ThreadList)

View File

@ -0,0 +1,339 @@
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { IconPlus, IconTrash, IconGripVertical } from '@tabler/icons-react'
import { MCPServerConfig } from '@/hooks/useMCPServers'
import {
DndContext,
closestCenter,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
} from '@dnd-kit/core'
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/utils'
// Sortable argument item component
function SortableArgItem({
id,
value,
onChange,
onRemove,
canRemove,
placeholder,
}: {
id: number
value: string
onChange: (value: string) => void
onRemove: () => void
canRemove: boolean
placeholder: string
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 mb-2',
isDragging ? 'z-10' : 'z-0'
)}
>
<div
{...attributes}
{...listeners}
className="size-6 cursor-move flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
>
<IconGripVertical size={18} className="text-main-view-fg/60" />
</div>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
{canRemove && (
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={onRemove}
>
<IconTrash size={18} className="text-destructive" />
</div>
)}
</div>
)
}
interface AddEditMCPServerProps {
open: boolean
onOpenChange: (open: boolean) => void
editingKey: string | null
initialData?: MCPServerConfig
onSave: (name: string, config: MCPServerConfig) => void
}
export default function AddEditMCPServer({
open,
onOpenChange,
editingKey,
initialData,
onSave,
}: AddEditMCPServerProps) {
const [serverName, setServerName] = useState('')
const [command, setCommand] = useState('')
const [args, setArgs] = useState<string[]>([''])
const [envKeys, setEnvKeys] = useState<string[]>([''])
const [envValues, setEnvValues] = useState<string[]>([''])
// Reset form when modal opens/closes or editing key changes
useEffect(() => {
if (open && editingKey && initialData) {
setServerName(editingKey)
setCommand(initialData.command)
setArgs(initialData.args.length > 0 ? initialData.args : [''])
// Convert env object to arrays of keys and values
const keys = Object.keys(initialData.env)
const values = keys.map((key) => initialData.env[key])
setEnvKeys(keys.length > 0 ? keys : [''])
setEnvValues(values.length > 0 ? values : [''])
} else if (open) {
// Add mode - reset form
resetForm()
}
}, [open, editingKey, initialData])
const resetForm = () => {
setServerName('')
setCommand('')
setArgs([''])
setEnvKeys([''])
setEnvValues([''])
}
const handleAddArg = () => {
setArgs([...args, ''])
}
const handleRemoveArg = (index: number) => {
const newArgs = [...args]
newArgs.splice(index, 1)
setArgs(newArgs.length > 0 ? newArgs : [''])
}
const handleArgChange = (index: number, value: string) => {
const newArgs = [...args]
newArgs[index] = value
setArgs(newArgs)
}
const handleReorderArgs = (oldIndex: number, newIndex: number) => {
setArgs(arrayMove(args, oldIndex, newIndex))
}
// Sensors for drag and drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 5,
},
}),
useSensor(KeyboardSensor)
)
const handleAddEnv = () => {
setEnvKeys([...envKeys, ''])
setEnvValues([...envValues, ''])
}
const handleRemoveEnv = (index: number) => {
const newKeys = [...envKeys]
const newValues = [...envValues]
newKeys.splice(index, 1)
newValues.splice(index, 1)
setEnvKeys(newKeys.length > 0 ? newKeys : [''])
setEnvValues(newValues.length > 0 ? newValues : [''])
}
const handleEnvKeyChange = (index: number, value: string) => {
const newKeys = [...envKeys]
newKeys[index] = value
setEnvKeys(newKeys)
}
const handleEnvValueChange = (index: number, value: string) => {
const newValues = [...envValues]
newValues[index] = value
setEnvValues(newValues)
}
const handleSave = () => {
// Convert env arrays to object
const envObj: Record<string, string> = {}
envKeys.forEach((key, index) => {
if (key.trim() !== '') {
envObj[key] = envValues[index] || ''
}
})
// Filter out empty args
const filteredArgs = args.filter((arg) => arg.trim() !== '')
const config: MCPServerConfig = {
command,
args: filteredArgs,
env: envObj,
}
if (serverName.trim() !== '') {
onSave(serverName, config)
onOpenChange(false)
resetForm()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingKey ? 'Edit MCP Server' : 'Add MCP Server'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">Server Name</label>
<Input
value={serverName}
onChange={(e) => setServerName(e.target.value)}
placeholder="Enter server name"
autoFocus
/>
</div>
<div className="space-y-2">
<label className="text-sm mb-2 inline-block">Command</label>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="Enter command (uvx or npx)"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm">Arguments</label>
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={handleAddArg}
>
<IconPlus size={18} className="text-main-view-fg/60" />
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = parseInt(active.id.toString())
const newIndex = parseInt(over?.id.toString() || '0')
handleReorderArgs(oldIndex, newIndex)
}
}}
>
<SortableContext
items={args.map((_, index) => index)}
strategy={verticalListSortingStrategy}
>
{args.map((arg, index) => (
<SortableArgItem
key={index}
id={index}
value={arg}
onChange={(value) => handleArgChange(index, value)}
onRemove={() => handleRemoveArg(index)}
canRemove={args.length > 1}
placeholder={`Argument ${index + 1}`}
/>
))}
</SortableContext>
</DndContext>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm">Environment Variables</label>
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={handleAddEnv}
>
<IconPlus size={18} className="text-main-view-fg/60" />
</div>
</div>
{envKeys.map((key, index) => (
<div key={`env-${index}`} className="flex items-center gap-2">
<Input
value={key}
onChange={(e) => handleEnvKeyChange(index, e.target.value)}
placeholder="Key"
className="flex-1"
/>
<Input
value={envValues[index] || ''}
onChange={(e) => handleEnvValueChange(index, e.target.value)}
placeholder="Value"
className="flex-1"
/>
{envKeys.length > 1 && (
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
onClick={() => handleRemoveEnv(index)}
>
<IconTrash size={18} className="text-destructive" />
</div>
)}
</div>
))}
</div>
</div>
<DialogFooter>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,117 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useModelProvider } from '@/hooks/useModelProvider'
import { IconPlus } from '@tabler/icons-react'
import { useState } from 'react'
import { getProviderTitle } from '@/lib/utils'
type DialogAddModelProps = {
provider: ModelProvider
trigger?: React.ReactNode
}
export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => {
const { updateProvider } = useModelProvider()
const [modelId, setModelId] = useState<string>('')
const [open, setOpen] = useState(false)
// Handle form submission
const handleSubmit = () => {
if (!modelId.trim()) {
return // Don't submit if model ID is empty
}
// Create the new model
const newModel = {
id: modelId,
model: modelId,
name: modelId,
capabilities: ['completion'], // Default capability
version: '1.0',
}
// Update the provider with the new model
const updatedModels = [...provider.models, newModel]
updateProvider(provider.provider, {
...provider,
models: updatedModels,
})
// Reset form and close dialog
setModelId('')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out">
<IconPlus size={18} className="text-main-view-fg/50" />
</div>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Model</DialogTitle>
<DialogDescription>
Add a new model to the {getProviderTitle(provider.provider)}
&nbsp;provider.
</DialogDescription>
</DialogHeader>
{/* Model ID field - required */}
<div className="space-y-2">
<label
htmlFor="model-id"
className="text-sm font-medium inline-block"
>
Model ID <span className="text-destructive">*</span>
</label>
<Input
id="model-id"
value={modelId}
onChange={(e) => setModelId(e.target.value)}
placeholder="Enter model ID"
required
/>
</div>
{/* Explore models link */}
{provider.explore_models_url && (
<div className="text-sm text-main-view-fg/70">
<a
href={provider.explore_models_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-primary"
>
<span>
See model list from {getProviderTitle(provider.provider)}
</span>
</a>
</div>
)}
<DialogFooter>
<Button
variant="default"
onClick={handleSubmit}
disabled={!modelId.trim()}
>
Add Model
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,49 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface DeleteMCPServerConfirmProps {
open: boolean
onOpenChange: (open: boolean) => void
serverName: string
onConfirm: () => void
}
export default function DeleteMCPServerConfirm({
open,
onOpenChange,
serverName,
onConfirm,
}: DeleteMCPServerConfirmProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete MCP Server</DialogTitle>
<DialogDescription>
Are you sure you want to delete the MCP server{' '}
<span className="font-medium text-main-view-fg">{serverName}</span>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="destructive"
onClick={() => {
onConfirm()
onOpenChange(false)
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,88 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { IconTrash } from '@tabler/icons-react'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
type DialoDeleteModelProps = {
provider: ModelProvider
modelId?: string
}
export const DialoDeleteModel = ({
provider,
modelId,
}: DialoDeleteModelProps) => {
const [selectedModelId, setSelectedModelId] = useState<string>('')
// Initialize with the provided model ID or the first model if available
useEffect(() => {
if (modelId) {
setSelectedModelId(modelId)
} else if (provider.models && provider.models.length > 0) {
setSelectedModelId(provider.models[0].id)
}
}, [provider, modelId])
// Get the currently selected model
const selectedModel = provider.models.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(m: any) => m.id === selectedModelId
)
if (!selectedModel) {
return null
}
return (
<Dialog>
<DialogTrigger asChild>
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out">
<IconTrash size={18} className="text-main-view-fg/50" />
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Model: {selectedModel.id}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this model? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="hover:no-underline">
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="destructive"
size="sm"
onClick={() => {
toast.success('Delete Model', {
id: `delete-model-${selectedModel.id}`,
description: `Model ${selectedModel.id} has been permanently deleted.`,
})
}}
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,88 @@
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { MCPServerConfig } from '@/hooks/useMCPServers'
import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
interface EditJsonMCPserverProps {
open: boolean
onOpenChange: (open: boolean) => void
serverName: string | null // null means editing all servers
initialData: MCPServerConfig | Record<string, MCPServerConfig>
onSave: (data: MCPServerConfig | Record<string, MCPServerConfig>) => void
}
export default function EditJsonMCPserver({
open,
onOpenChange,
serverName,
initialData,
onSave,
}: EditJsonMCPserverProps) {
const [jsonContent, setJsonContent] = useState('')
const [error, setError] = useState<string | null>(null)
// Initialize the editor with the provided data
useEffect(() => {
if (open && initialData) {
try {
setJsonContent(JSON.stringify(initialData, null, 2))
setError(null)
} catch {
setError('Failed to parse initial data')
}
}
}, [open, initialData])
const handleSave = () => {
try {
const parsedData = JSON.parse(jsonContent)
onSave(parsedData)
onOpenChange(false)
setError(null)
} catch {
setError('Invalid JSON format')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{serverName
? `Edit JSON for MCP Server: ${serverName}`
: 'Edit All MCP Servers JSON'}
</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
<CodeEditor
value={jsonContent}
language="json"
placeholder="Enter JSON configuration"
onChange={(e) => setJsonContent(e.target.value)}
style={{
fontFamily: 'ui-monospace',
backgroundColor: 'transparent',
}}
className="w-full "
/>
</div>
{error && <div className="text-destructive text-sm">{error}</div>}
</div>
<DialogFooter>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,220 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { useModelProvider } from '@/hooks/useModelProvider'
import {
IconPencil,
IconEye,
IconTool,
IconWorld,
IconAtom,
IconCodeCircle2,
} from '@tabler/icons-react'
import { useState, useEffect } from 'react'
// No need to define our own interface, we'll use the existing Model type
type DialogEditModelProps = {
provider: ModelProvider
modelId?: string // Optional model ID to edit
}
export const DialogEditModel = ({
provider,
modelId,
}: DialogEditModelProps) => {
const { updateProvider } = useModelProvider()
const [selectedModelId, setSelectedModelId] = useState<string>('')
const [capabilities, setCapabilities] = useState<Record<string, boolean>>({
completion: false,
vision: false,
tools: false,
reasoning: false,
embeddings: false,
web_search: false,
})
// Initialize with the provided model ID or the first model if available
useEffect(() => {
if (modelId) {
setSelectedModelId(modelId)
} else if (provider.models && provider.models.length > 0) {
setSelectedModelId(provider.models[0].id)
}
}, [provider, modelId])
// Get the currently selected model
const selectedModel = provider.models.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(m: any) => m.id === selectedModelId
)
// Initialize capabilities from selected model
useEffect(() => {
if (selectedModel) {
const modelCapabilities = selectedModel.capabilities || []
setCapabilities({
completion: modelCapabilities.includes('completion'),
vision: modelCapabilities.includes('vision'),
tools: modelCapabilities.includes('tools'),
embeddings: modelCapabilities.includes('embeddings'),
web_search: modelCapabilities.includes('web_search'),
reasoning: modelCapabilities.includes('reasoning'),
})
}
}, [selectedModel])
// Track if capabilities were updated by user action
const [capabilitiesUpdated, setCapabilitiesUpdated] = useState(false)
// Update model capabilities - only update local state
const handleCapabilityChange = (capability: string, enabled: boolean) => {
setCapabilities((prev) => ({
...prev,
[capability]: enabled,
}))
// Mark that capabilities were updated by user action
setCapabilitiesUpdated(true)
}
// Use effect to update the provider when capabilities are explicitly changed by user
useEffect(() => {
// Only run if capabilities were updated by user action and we have a selected model
if (!capabilitiesUpdated || !selectedModel) return
// Reset the flag
setCapabilitiesUpdated(false)
// Create updated capabilities array from the state
const updatedCapabilities = Object.entries(capabilities)
.filter(([, isEnabled]) => isEnabled)
.map(([capName]) => capName)
// Find and update the model in the provider
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updatedModels = provider.models.map((m: any) => {
if (m.id === selectedModelId) {
return {
...m,
capabilities: updatedCapabilities,
}
}
return m
})
// Update the provider with the updated models
updateProvider(provider.provider, {
...provider,
models: updatedModels,
})
}, [
capabilitiesUpdated,
capabilities,
provider,
selectedModel,
selectedModelId,
updateProvider,
])
if (!selectedModel) {
return null
}
return (
<Dialog>
<DialogTrigger asChild>
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out">
<IconPencil size={18} className="text-main-view-fg/50" />
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Model: {selectedModel.id}</DialogTitle>
<DialogDescription>
Configure model capabilities by toggling the options below.
</DialogDescription>
</DialogHeader>
<div className="py-1">
<h3 className="text-sm font-medium mb-3">Capabilities</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<IconEye className="size-4 text-main-view-fg/70" />
<span className="text-sm">Vision</span>
</div>
<Switch
id="vision-capability"
checked={capabilities.vision}
onCheckedChange={(checked) =>
handleCapabilityChange('vision', checked)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<IconTool className="size-4 text-main-view-fg/70" />
<span className="text-sm">Tools</span>
</div>
<Switch
id="tools-capability"
checked={capabilities.tools}
onCheckedChange={(checked) =>
handleCapabilityChange('tools', checked)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<IconCodeCircle2 className="size-4 text-main-view-fg/70" />
<span className="text-sm">Embeddings</span>
</div>
<Switch
id="embedding-capability"
checked={capabilities.embeddings}
onCheckedChange={(checked) =>
handleCapabilityChange('embeddings', checked)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<IconWorld className="size-4 text-main-view-fg/70" />
<span className="text-sm">Web Search</span>
</div>
<Switch
id="web_search-capability"
checked={capabilities.web_search}
onCheckedChange={(checked) =>
handleCapabilityChange('web_search', checked)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<IconAtom className="size-4 text-main-view-fg/70" />
<span className="text-sm">Reasoning</span>
</div>
<Switch
id="reasoning-capability"
checked={capabilities.reasoning}
onCheckedChange={(checked) =>
handleCapabilityChange('reasoning', checked)
}
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,13 @@
import { Switch } from '@/components/ui/switch'
// Checkbox or switch component
type CheckboxControlProps = {
checked: boolean
onChange: (checked: boolean) => void
}
export function CheckboxControl({ checked, onChange }: CheckboxControlProps) {
return (
<Switch checked={checked} onCheckedChange={(value) => onChange(value)} />
)
}

View File

@ -0,0 +1,37 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
// Dropdown component
type DropdownControlProps = {
value: string
options?: Array<{ value: string; name: string }>
onChange: (value: string) => void
}
export function DropdownControl({
value,
options = [],
onChange,
}: DropdownControlProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger className="bg-main-view-fg/5 hover:bg-main-view-fg/8 px-2 py-1 rounded font-medium cursor-pointer">
{options.find((option) => option.value === value)?.name || value}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{options.map((option, optionIndex) => (
<DropdownMenuItem
key={optionIndex}
onClick={() => onChange(option.value)}
>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

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