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>
3
Makefile
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
1617
extensions/yarn.lock
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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": [
|
||||
|
||||
14
src-tauri/capabilities/logs-window.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -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>,
|
||||
|
||||
@ -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(""),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
web-app/public/fonts/PPEditorialNew/PPEditorialNew-Regular.otf
Normal file
BIN
web-app/public/fonts/PPEditorialNew/PPEditorialNew-Ultrabold.otf
Normal file
9
web-app/public/images/model-provider/anthropic.svg
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
9
web-app/public/images/model-provider/cohere.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
14
web-app/public/images/model-provider/cortex.svg
Normal 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 |
25
web-app/public/images/model-provider/deepseek.svg
Normal 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 |
3
web-app/public/images/model-provider/dot.svg
Normal 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 |
1
web-app/public/images/model-provider/gemini.svg
Normal 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 |
1
web-app/public/images/model-provider/google-gemini.svg
Normal 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 |
9
web-app/public/images/model-provider/google.svg
Normal file
|
After Width: | Height: | Size: 62 KiB |
9
web-app/public/images/model-provider/groq.svg
Normal 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 |
8
web-app/public/images/model-provider/hugging-face.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
9
web-app/public/images/model-provider/llamacpp.svg
Normal file
|
After Width: | Height: | Size: 52 KiB |
11
web-app/public/images/model-provider/martian.svg
Normal 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 |
72
web-app/public/images/model-provider/meta.svg
Normal 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 |
28
web-app/public/images/model-provider/mistral.svg
Normal 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 |
6
web-app/public/images/model-provider/nitro.svg
Normal 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 |
10
web-app/public/images/model-provider/nvidia.svg
Normal 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 |
14
web-app/public/images/model-provider/openRouter.svg
Normal 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 |
9
web-app/public/images/model-provider/openai.svg
Normal file
|
After Width: | Height: | Size: 146 KiB |
3
web-app/public/images/model-provider/send.svg
Normal 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 |
53
web-app/src/components/ui/button.tsx
Normal 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 }
|
||||
133
web-app/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
250
web-app/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
42
web-app/src/components/ui/hover-card.tsx
Normal 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 }
|
||||
22
web-app/src/components/ui/input.tsx
Normal 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 }
|
||||
46
web-app/src/components/ui/popover.tsx
Normal 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 }
|
||||
29
web-app/src/components/ui/progress.tsx
Normal 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 }
|
||||
137
web-app/src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
61
web-app/src/components/ui/slider.tsx
Normal 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 }
|
||||
20
web-app/src/components/ui/sonner.tsx
Normal 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 }
|
||||
29
web-app/src/components/ui/switch.tsx
Normal 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 }
|
||||
19
web-app/src/components/ui/textarea.tsx
Normal 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 }
|
||||
59
web-app/src/components/ui/tooltip.tsx
Normal 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 }
|
||||
12
web-app/src/constants/localStorage.ts
Normal 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',
|
||||
}
|
||||
19
web-app/src/constants/routes.ts
Normal 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',
|
||||
}
|
||||
34
web-app/src/containers/ApiPrefixInput.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
70
web-app/src/containers/Capabilities.tsx
Normal 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
|
||||
65
web-app/src/containers/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
353
web-app/src/containers/ChatInput.tsx
Normal 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
|
||||
25
web-app/src/containers/CodeBlockExample.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
271
web-app/src/containers/CodeBlockStyleSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
web-app/src/containers/ColorPickerAppAccentColor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
web-app/src/containers/ColorPickerAppBgColor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
web-app/src/containers/ColorPickerAppDestructiveColor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
web-app/src/containers/ColorPickerAppMainView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
web-app/src/containers/ColorPickerAppPrimaryColor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
web-app/src/containers/DownloadManegement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
web-app/src/containers/DropdownModelProvider.tsx
Normal 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
|
||||
41
web-app/src/containers/FontSizeSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
web-app/src/containers/HeaderPage.tsx
Normal 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
|
||||
55
web-app/src/containers/LanguageSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
315
web-app/src/containers/LeftPanel.tsx
Normal 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
|
||||
14
web-app/src/containers/LineNumbersSwitcher.tsx
Normal 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} />
|
||||
)
|
||||
}
|
||||
152
web-app/src/containers/ModelSetting.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
web-app/src/containers/MovingBorder.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
web-app/src/containers/PlatformMetaKey.tsx
Normal 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}</>
|
||||
}
|
||||
35
web-app/src/containers/PortInput.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
58
web-app/src/containers/ProvidersMenu.tsx
Normal 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
|
||||
173
web-app/src/containers/RenderMarkdown.tsx
Normal 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)
|
||||
44
web-app/src/containers/ServerHostSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
web-app/src/containers/SettingsMenu.tsx
Normal 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
|
||||
14
web-app/src/containers/StreamingContent.tsx
Normal 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} />
|
||||
})
|
||||
46
web-app/src/containers/ThemeSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
web-app/src/containers/ThinkingBlock.tsx
Normal 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
|
||||
177
web-app/src/containers/ThreadContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
324
web-app/src/containers/ThreadList.tsx
Normal 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)
|
||||
339
web-app/src/containers/dialogs/AddEditMCPServer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
web-app/src/containers/dialogs/AddModel.tsx
Normal 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)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
49
web-app/src/containers/dialogs/DeleteMCPServerConfirm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
web-app/src/containers/dialogs/DeleteModel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
web-app/src/containers/dialogs/EditJsonMCPserver.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
web-app/src/containers/dialogs/EditModel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)} />
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||