Compare commits

...

22 Commits

Author SHA1 Message Date
Benson Wong d567fa78cb npm audit fix 2026-06-28 04:38:45 +00:00
Benson Wong 187f1ae27a ui: fix logs tab height and column toggle dropdown
- Make ModelLogsTab fill available vertical space instead of fixed h-80
- Add min-h-0 flex-1 to Logs Tabs.Content so height propagates
- Set closeOnSelect=false on column visibility checkbox items to keep
  the dropdown open while toggling multiple columns
2026-06-28 04:36:56 +00:00
Benson Wong 0ae56b1eb9 ui: convert chat settings panel to a dialog
Replace the inline settings panel with a modal Dialog that pops up
over the chat interface, matching the CaptureDialog pattern.
2026-06-28 04:22:01 +00:00
Benson Wong e46cbeb2bf ui: refocus message input after chat generation completes
- Add ref prop to ExpandableTextarea to expose the underlying textarea
- Track streaming state transitions in ChatInterface and refocus the
  input via $effect when isStreaming flips to false
2026-06-28 04:16:23 +00:00
Benson Wong a0578f0007 ui: reorganize sidebar and add Settings page
Reorder sidebar menu to Activity, Playground, Models, Logs. Remove the
ll header icon and replace it with the connection status indicator
moved from the footer. Add a Settings page (gear icon) at the bottom
that surfaces the build information that was previously hidden behind
the status indicator's tooltip.

- move ConnectionStatus into Sidebar.Header, drop build-info tooltip
- add Settings.svelte route showing version/commit/build date
- register /settings route and title in App.svelte
2026-06-28 03:53:14 +00:00
Benson Wong d207a059a4 ui: enable pagination on Activity page and fix table reactivity
- add showPagination to Activity route's ActivityTable
- fix pagination reactivity: reassign pagination object in
  onPaginationChange so TanStack's effect.pre detects the change, and
  reset to first page only when pageSize changes
- move data-change page reset into untrack to avoid clobbering
  navigation
- render Cached/Prompt/Drafted headers with a dotted underline trigger
  instead of a separate info icon
2026-06-28 03:43:55 +00:00
Benson Wong 040ee1e284 ui: convert ActivityTable to shadcn-svelte data-table
Replace the hand-rolled table with a TanStack Table-backed shadcn
data-table using the FlexRender/createSvelteTable helpers, with
DropdownMenu column visibility, Select page-size, and icon-button
pagination. Column visibility and page size persist to localStorage.

Move tooltip usage to the canonical shadcn-svelte pattern by adding a
single root Tooltip.Provider in App.svelte and using Tooltip.Root/
Trigger/Content directly in the activity-table sub-components
(HeaderLabel, MetaCell), dropping the custom Tooltip/MetadataTooltip
wrappers.

- add @tanstack/table-core and shadcn data-table helpers
- split cell/header renderers into activity-table/* sub-components
- switch pagination/visibility to TanStack Table state driven by
  table.nextPage/previousPage/setPageIndex/setPageSize and
  column.toggleVisibility
2026-06-28 03:26:24 +00:00
Benson Wong 82cad1b84e ui: add ModelsDash route, clickable sidebar headings, and dialog tweaks
- Add /models route (ModelsDash) with unload-all, model list with
  start/stop buttons, and show-unlisted toggle
- Make sidebar Models and Playground headings navigate to their pages
  while the chevron independently expands/collapses the section
- Extract shared model load/unload orchestration into modelLoad store
- Left-align model names in the ConcurrencyInterface load-test list
- Widen CaptureDialog to 90% with flex-based scroll overflow

- Use sm:max-w-[90%] to override the shadcn dialog's sm:max-w-sm cap
2026-06-28 03:04:04 +00:00
Benson Wong 55c3678906 ui: extract shared ActivityTable and split ModelDetail into components
- Add ActivityTable component consolidating column customization,
  table rendering, pagination, and capture dialog previously
  duplicated between Activity.svelte and ModelDetail.svelte
- Split ModelDetail tabs into ModelActivityTab, ModelLogsTab, and
  ModelDetailsTab components under components/model/
- Reduce Activity.svelte and ModelDetail.svelte to thin shells

- ModelDetail tabs now reuse ActivityTable instead of duplicating
  column management, formatting, and capture logic
2026-06-28 02:27:05 +00:00
Benson Wong 8b5a62d92a ui-svelte: big convert to shadcn components 2026-06-28 01:53:19 +00:00
Benson Wong d1e4c8ee77 ui tweaks 2026-06-28 01:21:40 +00:00
Benson Wong 11f8afead8 ui: add collapsible Models section to sidebar
Move Models to the top of the sidebar as a collapsible item with each
model listed as a sub-item.

- add persistent modelsMenuOpen store for expand state
- show status dot per model (grey/yellow/green for stopped/changing/loaded)
- right-aligned load/unload button with Play/PowerOff/Loader2 icon
- button stops propagation so it doesn't trigger navigation
2026-06-27 23:54:18 +00:00
Benson Wong 749819ef47 ui: consolidate playground nav into sidebar
Move Playground tabs into the sidebar as collapsible sub-items and make
the sidebar the sole navigation for playground interfaces.

- add collapsible UI primitive (bits-ui wrapper)
- add playground store with selected tab and menu open state (persistent)
- make Playground menu item collapsible; whole button toggles expand state
- move playground sub-items (Chat/Images/Speech/etc) under Playground
- remove in-page Tabs from Playground.svelte
- update sectionTitle breadcrumb to reflect active sub-item
- remove bg-sidebar panel background so items sit on page background
- remove persistent data-active background tint on menu items

fixes #123
2026-06-27 16:46:10 +00:00
Claude 0ab9e74333 ui: finish shadcn migration and remove legacy shim
Convert the remaining .btn usages (Concurrency, Performance, CaptureDialog)
to shadcn Button, fix CaptureDialog/PerformanceChart styles to shadcn
tokens, and remove the transitional legacy palette aliases and component
classes from index.css. Drop the now-unused lucide-svelte and shadcn-svelte
dependencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 12:10:56 +00:00
Claude b20be6dcd1 ui: convert Image, Speech, Audio interfaces to shadcn buttons
Replace .btn elements and inline SVG icons with shadcn Button and
@lucide/svelte icons in the image, speech and audio playground tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 12:05:19 +00:00
Claude fc24722258 ui: migrate Rerank and normalize remaining views to shadcn tokens
- RerankInterface uses Button/Input/Textarea/ToggleGroup
- normalize legacy color utilities and lucide imports across the
  remaining playground interfaces, Performance and CaptureDialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 12:01:19 +00:00
Claude 2b087dffb1 ui: migrate ChatMessage to shadcn tokens
Use shadcn Button/Textarea, @lucide/svelte icons, and map prose/code-block
styles to shadcn CSS variables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:58:24 +00:00
Claude 746c083a87 ui: migrate chat playground and stats to shadcn
- ChatInterface controls, settings, input use Button/Input/Textarea/Label
- ExpandableTextarea and ModelSelector restyled on shadcn tokens
- ActivityStats wrapped in Card; Tooltip uses shadcn tooltip

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:56:31 +00:00
Claude 8dd91e99e8 ui: migrate Activity, Logs views to shadcn
- Activity table wrapped in Card with restyled column menu and Button
- LogPanel toolbar uses Button/Input with lucide icons
- LogViewer source switch uses a ToggleGroup

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:52:11 +00:00
Claude 136dcdc25f ui: migrate Models panel and Playground to shadcn
- ModelsPanel uses Card, Button, Badge and a dropdown menu for actions
- Playground uses shadcn Tabs for the switcher while keeping every
  interface mounted to preserve state

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:49:16 +00:00
Claude 767b8015fa ui: replace top navbar with shadcn sidebar layout
Add AppSidebar built from the shadcn sidebar primitives (collapsible icon
rail, editable title, nav with active states, footer theme toggle and
connection status) and wrap the app in a sidebar provider with an inset
top bar. Preserves the always-mounted Playground pattern.

- add src/components/AppSidebar.svelte
- restructure App.svelte around Sidebar.Provider/Inset
- remove Header.svelte

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:46:30 +00:00
Claude f0144a2361 ui: add shadcn-svelte foundation and theming
Set up shadcn-svelte components and adopt its design-token system as the
base for modernizing the UI. Switch dark mode from the data-theme attribute
to the .dark class so shadcn primitives theme correctly.

- add components.json, $lib alias (tsconfig + vite), cn() util
- install shadcn primitives under src/lib/components/ui
- rewrite index.css with shadcn tokens (zinc + brand teal accent)
- keep legacy utility/class aliases as a transitional shim
- toggle .dark class from theme store in App.svelte

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:42:43 +00:00
190 changed files with 6432 additions and 2006 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/index.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"iconLibrary": "lucide"
}
+421 -120
View File
@@ -8,10 +8,10 @@
"name": "ui-svelte", "name": "ui-svelte",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/table-core": "^8.21.3",
"chart.js": "4.5.1", "chart.js": "4.5.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-svelte": "^0.563.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -23,14 +23,22 @@
"unist-util-visit": "^5.1.0" "unist-util-visit": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.21.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/node": "^25.1.0", "@types/node": "^25.1.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"paneforge": "^1.0.2",
"svelte": "^5.46.4", "svelte": "^5.46.4",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^8.0.0", "vite": "^8.0.0",
"vite-plugin-compression2": "^2.5.1", "vite-plugin-compression2": "^2.5.1",
@@ -38,21 +46,21 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.2", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.1", "@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.9.2", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -61,9 +69,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -71,6 +79,44 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true,
"license": "MIT"
},
"node_modules/@internationalized/date": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -127,15 +173,25 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lucide/svelte": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.21.0.tgz",
"integrity": "sha512-MEv//A7Jv3kHukZowv/DWp1MAtUzJKYwtJsmnQ7X98lCgtac3z3NbaToDl3Q6jO3gS9sougFpcD+t+YuxOkRMw==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"svelte": "^5"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.1" "@tybys/wasm-util": "^0.10.3"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -147,9 +203,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.124.0", "version": "0.137.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -157,9 +213,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -174,9 +230,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -191,9 +247,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -208,9 +264,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -225,9 +281,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -242,13 +298,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -259,13 +318,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -276,13 +338,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -293,13 +358,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -310,13 +378,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -327,13 +398,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -344,9 +418,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -361,9 +435,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@@ -371,18 +445,18 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "1.9.2", "@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.9.2", "@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.3" "@napi-rs/wasm-runtime": "^1.1.6"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -397,9 +471,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -414,9 +488,9 @@
} }
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.15", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -480,6 +554,16 @@
"vite": "^8.0.0-beta.7 || ^8.0.0" "vite": "^8.0.0-beta.7 || ^8.0.0"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
@@ -752,6 +836,19 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tsconfig/svelte": { "node_modules/@tsconfig/svelte": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
@@ -760,9 +857,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1053,6 +1150,31 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/bits-ui": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz",
"integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.1",
"@floating-ui/dom": "^1.7.1",
"esm-env": "^1.1.2",
"runed": "^0.35.1",
"svelte-toolbelt": "^0.10.6",
"tabbable": "^6.2.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/huntabyte"
},
"peerDependencies": {
"@internationalized/date": "^3.8.1",
"svelte": "^5.33.0"
}
},
"node_modules/ccount": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -1555,6 +1677,13 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"dev": true,
"license": "MIT"
},
"node_modules/is-plain-obj": { "node_modules/is-plain-obj": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -1881,13 +2010,14 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/lucide-svelte": { "node_modules/lz-string": {
"version": "0.563.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.563.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "ISC", "dev": true,
"peerDependencies": { "license": "MIT",
"svelte": "^3 || ^4 || ^5.0.0-next.42" "bin": {
"lz-string": "bin/bin.js"
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
@@ -2738,9 +2868,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2767,6 +2897,74 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/paneforge": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/paneforge/-/paneforge-1.0.2.tgz",
"integrity": "sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA==",
"dev": true,
"license": "MIT",
"dependencies": {
"runed": "^0.23.4",
"svelte-toolbelt": "^0.9.2"
},
"peerDependencies": {
"svelte": "^5.29.0"
}
},
"node_modules/paneforge/node_modules/runed": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/paneforge/node_modules/svelte-toolbelt": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.3.tgz",
"integrity": "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.29.0",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.30.2"
}
},
"node_modules/paneforge/node_modules/svelte-toolbelt/node_modules/runed": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz",
"integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -2807,9 +3005,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.12", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2827,7 +3025,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -2985,14 +3183,14 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.124.0", "@oxc-project/types": "=0.137.0",
"@rolldown/pluginutils": "1.0.0-rc.15" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@@ -3001,21 +3199,46 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-android-arm64": "1.1.3",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.1.3",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.1.3",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.1.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.1.3",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.1.3",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.1.3",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.1.3",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.1.3",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.1.3",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.1.3",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.1.3",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.1.3",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" "@rolldown/binding-win32-x64-msvc": "1.1.3"
}
},
"node_modules/runed": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
"integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"esm-env": "^1.0.0",
"lz-string": "^1.5.0"
},
"peerDependencies": {
"@sveltejs/kit": "^2.21.0",
"svelte": "^5.7.0"
},
"peerDependenciesMeta": {
"@sveltejs/kit": {
"optional": true
}
} }
}, },
"node_modules/sade": { "node_modules/sade": {
@@ -3086,6 +3309,16 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.7"
}
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.55.7", "version": "5.55.7",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
@@ -3150,6 +3383,65 @@
"url": "https://github.com/sponsors/ItalyPaleAle" "url": "https://github.com/sponsors/ItalyPaleAle"
} }
}, },
"node_modules/svelte-toolbelt": {
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
"integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.35.1",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.30.2"
}
},
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwind-variants": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
"tailwind-merge": ">=3.0.0",
"tailwindcss": "*"
},
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
}
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
@@ -3196,14 +3488,14 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3" "picomatch": "^4.0.4"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -3247,8 +3539,17 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "dev": true,
"license": "0BSD", "license": "0BSD"
"optional": true },
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
@@ -3429,17 +3730,17 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.8", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"postcss": "^8.5.8", "postcss": "^8.5.15",
"rolldown": "1.0.0-rc.15", "rolldown": "~1.1.2",
"tinyglobby": "^0.2.15" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@@ -3455,7 +3756,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0", "@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
+10 -2
View File
@@ -12,30 +12,38 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.21.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/node": "^25.1.0", "@types/node": "^25.1.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"paneforge": "^1.0.2",
"svelte": "^5.46.4", "svelte": "^5.46.4",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^8.0.0", "vite": "^8.0.0",
"vite-plugin-compression2": "^2.5.1", "vite-plugin-compression2": "^2.5.1",
"vitest": "^4.1.0" "vitest": "^4.1.0"
}, },
"dependencies": { "dependencies": {
"@tanstack/table-core": "^8.21.3",
"chart.js": "4.5.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-svelte": "^0.563.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"chart.js": "4.5.1",
"svelte-spa-router": "^4.0.1", "svelte-spa-router": "^4.0.1",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-visit": "^5.1.0" "unist-util-visit": "^5.1.0"
+56 -10
View File
@@ -1,33 +1,68 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import Router from "svelte-spa-router"; import Router from "svelte-spa-router";
import Header from "./components/Header.svelte"; import AppSidebar from "./components/AppSidebar.svelte";
import LogViewer from "./routes/LogViewer.svelte"; import LogViewer from "./routes/LogViewer.svelte";
import Models from "./routes/Models.svelte"; import ModelDetail from "./routes/ModelDetail.svelte";
import ModelsDash from "./routes/ModelsDash.svelte";
import Activity from "./routes/Activity.svelte"; import Activity from "./routes/Activity.svelte";
import Performance from "./routes/Performance.svelte"; import Performance from "./routes/Performance.svelte";
import Playground from "./routes/Playground.svelte"; import Playground from "./routes/Playground.svelte";
import PlaygroundStub from "./routes/PlaygroundStub.svelte"; import PlaygroundStub from "./routes/PlaygroundStub.svelte";
import Settings from "./routes/Settings.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api"; import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api";
import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme"; import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme";
import { currentRoute } from "./stores/route"; import { currentRoute } from "./stores/route";
import { selectedPlaygroundTab, playgroundTabs } from "./stores/playground";
const routes = { const routes = {
"/": PlaygroundStub, "/": PlaygroundStub,
"/models": Models, "/models": ModelsDash,
"/models/:id": ModelDetail,
"/logs": LogViewer, "/logs": LogViewer,
"/activity": Activity, "/activity": Activity,
"/settings": Settings,
"/performance": Performance, "/performance": Performance,
"*": PlaygroundStub, "*": PlaygroundStub,
}; };
function handleRouteLoaded(event: { detail: { route: string | RegExp } }) { const routeTitles: Record<string, string> = {
"/": "Playground",
"/models": "Models",
"/activity": "Activity",
"/logs": "Logs",
"/settings": "Settings",
"/performance": "Performance",
};
let sectionTitle = $derived.by(() => {
if ($currentRoute === "/") {
const tab = playgroundTabs.find((t) => t.id === $selectedPlaygroundTab);
return `Playground / ${tab?.label ?? ""}`;
}
if ($currentRoute.startsWith("/models/")) {
const id = $currentRoute.slice("/models/".length);
return id ? `Models / ${decodeURIComponent(id)}` : "Models";
}
if ($currentRoute === "/models") {
return "Models";
}
return routeTitles[$currentRoute] ?? "Playground";
});
function handleRouteLoaded(event: { detail: { route: string | RegExp; location?: string } }) {
const route = event.detail.route; const route = event.detail.route;
currentRoute.set(typeof route === "string" ? route : "/"); // Prefer the actual URL path so parameterised routes (e.g. /models/:id)
// are reflected accurately in currentRoute for sidebar highlighting.
const loc = event.detail.location;
currentRoute.set(loc ?? (typeof route === "string" ? route : "/"));
} }
$effect(() => { $effect(() => {
document.documentElement.setAttribute("data-theme", $isDarkMode ? "dark" : "light"); document.documentElement.classList.toggle("dark", $isDarkMode);
}); });
$effect(() => { $effect(() => {
@@ -49,10 +84,19 @@
}); });
</script> </script>
<div class="flex flex-col h-screen"> <Tooltip.Provider>
<Header /> <Sidebar.Provider>
<AppSidebar />
<Sidebar.Inset class="h-screen min-w-0 overflow-hidden">
<header
class="bg-background sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b px-4"
>
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 !h-4" />
<h2 class="truncate pb-0 text-sm font-semibold">{sectionTitle}</h2>
</header>
<main class="flex-1 overflow-auto p-4"> <main class="min-h-0 flex-1 overflow-auto p-4">
<div class="h-full" class:hidden={$currentRoute !== "/"}> <div class="h-full" class:hidden={$currentRoute !== "/"}>
<Playground /> <Playground />
</div> </div>
@@ -60,4 +104,6 @@
<Router {routes} on:routeLoaded={handleRouteLoaded} /> <Router {routes} on:routeLoaded={handleRouteLoaded} />
</div> </div>
</main> </main>
</div> </Sidebar.Inset>
</Sidebar.Provider>
</Tooltip.Provider>
+27 -26
View File
@@ -3,6 +3,9 @@
import { persistentStore } from "../stores/persistent"; import { persistentStore } from "../stores/persistent";
import { calculateHistogramData } from "../lib/histogram"; import { calculateHistogramData } from "../lib/histogram";
import TokenHistogram from "./TokenHistogram.svelte"; import TokenHistogram from "./TokenHistogram.svelte";
import { ChevronDown, X } from "@lucide/svelte";
import * as Card from "$lib/components/ui/card/index.js";
import { Button } from "$lib/components/ui/button/index.js";
const nf = new Intl.NumberFormat(); const nf = new Intl.NumberFormat();
const histogramCollapsed = persistentStore<boolean>("activity-histogram-collapsed", false); const histogramCollapsed = persistentStore<boolean>("activity-histogram-collapsed", false);
@@ -35,26 +38,24 @@
}); });
</script> </script>
<div class="card relative p-3"> <Card.Root class="relative p-3">
<button <Button
class="absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-400 transition-colors" variant="ghost"
size="icon-xs"
class="text-muted-foreground absolute right-2 top-2 rounded-full"
onclick={() => ($histogramCollapsed = !$histogramCollapsed)} onclick={() => ($histogramCollapsed = !$histogramCollapsed)}
title={$histogramCollapsed ? "Show histograms" : "Hide histograms"} title={$histogramCollapsed ? "Show histograms" : "Hide histograms"}
> >
{#if $histogramCollapsed} {#if $histogramCollapsed}
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"> <ChevronDown />
<path d="M4.5 6l3.5 4 3.5-4H4.5z" />
</svg>
{:else} {:else}
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor"> <X />
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" />
</svg>
{/if} {/if}
</button> </Button>
{#if !$histogramCollapsed} {#if !$histogramCollapsed}
<div class="flex flex-col sm:flex-row gap-6 mb-3"> <div class="mb-3 flex flex-col gap-6 sm:flex-row">
<div class="w-full sm:w-1/2 min-w-0"> <div class="w-full min-w-0 sm:w-1/2">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prompt Processing</div> <div class="text-muted-foreground mb-1 text-sm font-medium">Prompt Processing</div>
{#if stats.promptHistogramData} {#if stats.promptHistogramData}
<TokenHistogram <TokenHistogram
data={stats.promptHistogramData} data={stats.promptHistogramData}
@@ -62,36 +63,36 @@
colorClass="text-amber-500 dark:text-amber-400" colorClass="text-amber-500 dark:text-amber-400"
/> />
{:else} {:else}
<div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">No prompt speed data yet</div> <div class="text-muted-foreground py-6 text-center text-sm">No prompt speed data yet</div>
{/if} {/if}
</div> </div>
<div class="w-full sm:w-1/2 min-w-0"> <div class="w-full min-w-0 sm:w-1/2">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Token Generation</div> <div class="text-muted-foreground mb-1 text-sm font-medium">Token Generation</div>
{#if stats.genHistogramData} {#if stats.genHistogramData}
<TokenHistogram data={stats.genHistogramData} unit="tokens/sec" /> <TokenHistogram data={stats.genHistogramData} unit="tokens/sec" />
{:else} {:else}
<div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">No generation speed data yet</div> <div class="text-muted-foreground py-6 text-center text-sm">No generation speed data yet</div>
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
<div class="grid grid-cols-4 gap-x-6 gap-y-1 text-sm"> <div class="grid grid-cols-4 gap-x-6 gap-y-1 text-sm">
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Requests</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Requests</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Cached</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Cached</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Processed</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Processed</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Generated</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Generated</div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalRequests)}</span> completed, <span class="font-semibold">{nf.format(stats.totalRequests)}</span> completed,
<span class="font-semibold">{nf.format(stats.inFlightRequests)}</span> waiting <span class="font-semibold">{nf.format(stats.inFlightRequests)}</span> waiting
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalCacheTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalCacheTokens)}</span> tokens
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalInputTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalInputTokens)}</span> tokens
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalOutputTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalOutputTokens)}</span> tokens
</div> </div>
</div> </div>
</div> </Card.Root>
@@ -0,0 +1,449 @@
<script lang="ts">
import { untrack } from "svelte";
import type { ActivityLogEntry, ReqRespCapture } from "../lib/types";
import { getCapture } from "../stores/api";
import { persistentStore } from "../stores/persistent";
import CaptureDialog from "./CaptureDialog.svelte";
import {
type ColumnDef,
type PaginationState,
type VisibilityState,
getCoreRowModel,
getPaginationRowModel,
} from "@tanstack/table-core";
import {
FlexRender,
createSvelteTable,
renderComponent,
} from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import {
Columns3,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "@lucide/svelte";
import HeaderLabel from "./activity-table/HeaderLabel.svelte";
import ViewCaptureButton from "./activity-table/ViewCaptureButton.svelte";
import MetaCell from "./activity-table/MetaCell.svelte";
interface Props {
metrics: ActivityLogEntry[];
storagePrefix: string;
showModelColumn?: boolean;
showPagination?: boolean;
title?: string;
compact?: boolean;
emptyMessage?: string;
cardClass?: string;
}
let {
metrics,
storagePrefix,
showModelColumn = true,
showPagination = false,
title,
compact = false,
emptyMessage = "No activity recorded",
cardClass = "",
}: Props = $props();
function formatSpeed(speed: number): string {
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
}
function formatDuration(ms: number): string {
return (ms / 1000).toFixed(2) + "s";
}
function formatRelativeTime(timestamp: string): string {
const now = new Date();
const date = new Date(timestamp);
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 5) return "now";
if (diffInSeconds < 60) return `${diffInSeconds}s ago`;
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}h ago`;
return "a while ago";
}
function formatDrafted(drafted: number, accepted: number): string {
return drafted > 0
? ((accepted * 100) / drafted).toFixed(1) + "% (" + accepted + "/" + drafted + ")"
: "-";
}
interface ColMeta {
id: string;
label: string;
defaultVisible: boolean;
}
function buildColumnMeta(withModel: boolean): ColMeta[] {
const cols: ColMeta[] = [
{ id: "id", label: "ID", defaultVisible: true },
{ id: "time", label: "Time", defaultVisible: true },
];
if (withModel) cols.push({ id: "model", label: "Model", defaultVisible: true });
cols.push(
{ id: "req_path", label: "Path", defaultVisible: false },
{ id: "resp_status_code", label: "Status", defaultVisible: true },
{ id: "resp_content_type", label: "Content-Type", defaultVisible: false },
{ id: "cached", label: "Cached", defaultVisible: true },
{ id: "prompt", label: "Prompt", defaultVisible: true },
{ id: "generated", label: "Generated", defaultVisible: true },
{ id: "drafted", label: "Drafted", defaultVisible: false },
{ id: "prompt_speed", label: "Prompt Speed", defaultVisible: true },
{ id: "gen_speed", label: "Gen Speed", defaultVisible: true },
{ id: "duration", label: "Duration", defaultVisible: true },
{ id: "capture", label: "Capture", defaultVisible: true },
{ id: "meta", label: "Meta", defaultVisible: false }
);
return cols;
}
let columnMeta = $derived(buildColumnMeta(showModelColumn));
let columnLabelMap = $derived(
Object.fromEntries(columnMeta.map((c) => [c.id, c.label])) as Record<string, string>
);
let defaultVisibility = $derived.by(() => {
const v: VisibilityState = {};
for (const c of columnMeta) v[c.id] = c.defaultVisible;
return v;
});
// svelte-ignore state_referenced_locally
const storedVisibility = persistentStore<VisibilityState>(
`${storagePrefix}-columns`,
{}
);
// svelte-ignore state_referenced_locally
let columnVisibility = $state<VisibilityState>(
Object.keys($storedVisibility).length > 0 ? $storedVisibility : defaultVisibility
);
// svelte-ignore state_referenced_locally
const storedPageSize = persistentStore<number>(`${storagePrefix}-page-size`, 10);
// When not paginating, use a large page size so all rows render in one page.
// svelte-ignore state_referenced_locally
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: showPagination ? $storedPageSize : Number.MAX_SAFE_INTEGER,
});
// Reset to the first page when the data source changes. We deliberately do
// NOT track pagination here — page-size changes reset pageIndex inside
// onPaginationChange instead, to avoid clobbering page navigation.
$effect(() => {
metrics;
untrack(() => {
pagination = { ...pagination, pageIndex: 0 };
});
});
let selectedCapture = $state<ReqRespCapture | null>(null);
let dialogOpen = $state(false);
let loadingCaptureId = $state<number | null>(null);
async function viewCapture(id: number) {
loadingCaptureId = id;
const capture = await getCapture(id);
loadingCaptureId = null;
selectedCapture = capture;
dialogOpen = true;
}
function closeDialog() {
dialogOpen = false;
selectedCapture = null;
}
function buildColumns(withModel: boolean): ColumnDef<ActivityLogEntry>[] {
const cols: ColumnDef<ActivityLogEntry>[] = [
{
id: "id",
header: "ID",
cell: ({ row }) => String(row.original.id + 1),
},
{
id: "time",
header: "Time",
cell: ({ row }) => formatRelativeTime(row.original.timestamp),
},
];
if (withModel) {
cols.push({
id: "model",
header: "Model",
cell: ({ row }) => row.original.model ?? "-",
});
}
cols.push(
{
id: "req_path",
header: "Path",
cell: ({ row }) => row.original.req_path || "-",
},
{
id: "resp_status_code",
header: "Status",
cell: ({ row }) => String(row.original.resp_status_code || "-"),
},
{
id: "resp_content_type",
header: "Content-Type",
cell: ({ row }) => row.original.resp_content_type || "-",
},
{
id: "cached",
header: () => renderComponent(HeaderLabel, { label: "Cached", tooltip: "prompt tokens from cache" }),
cell: ({ row }) =>
row.original.tokens.cache_tokens > 0
? row.original.tokens.cache_tokens.toLocaleString()
: "-",
},
{
id: "prompt",
header: () => renderComponent(HeaderLabel, { label: "Prompt", tooltip: "new prompt tokens processed" }),
cell: ({ row }) => row.original.tokens.input_tokens.toLocaleString(),
},
{
id: "generated",
header: "Generated",
cell: ({ row }) => row.original.tokens.output_tokens.toLocaleString(),
},
{
id: "drafted",
header: () => renderComponent(HeaderLabel, { label: "Drafted", tooltip: "acceptance rate (accepted/drafted)" }),
cell: ({ row }) =>
formatDrafted(row.original.tokens.draft_tokens, row.original.tokens.draft_acc_tokens),
},
{
id: "prompt_speed",
header: "Prompt Speed",
cell: ({ row }) => formatSpeed(row.original.tokens.prompt_per_second),
},
{
id: "gen_speed",
header: "Gen Speed",
cell: ({ row }) => formatSpeed(row.original.tokens.tokens_per_second),
},
{
id: "duration",
header: "Duration",
cell: ({ row }) => formatDuration(row.original.duration_ms),
},
{
id: "capture",
header: "Capture",
cell: ({ row }) =>
renderComponent(ViewCaptureButton, {
hasCapture: row.original.has_capture,
loading: loadingCaptureId === row.original.id,
onclick: () => viewCapture(row.original.id),
}),
},
{
id: "meta",
header: "Meta",
cell: ({ row }) =>
renderComponent(MetaCell, { metadata: row.original.metadata }),
}
);
return cols;
}
let columns = $derived(buildColumns(showModelColumn));
const table = createSvelteTable({
get data() {
return metrics;
},
get columns() {
return columns;
},
state: {
get pagination() {
return pagination;
},
get columnVisibility() {
return columnVisibility;
},
},
onPaginationChange: (updater) => {
const prev = pagination;
const next =
typeof updater === "function" ? updater(prev) : updater;
// Reassign so the table's $effect.pre (which reads state.pagination)
// picks up the new value. Reset to first page when the page size
// changes so we don't land on an empty page.
pagination =
next.pageSize !== prev.pageSize
? { pageIndex: 0, pageSize: next.pageSize }
: next;
if (showPagination) storedPageSize.set(pagination.pageSize);
},
onColumnVisibilityChange: (updater) => {
columnVisibility =
typeof updater === "function" ? updater(columnVisibility) : updater;
storedVisibility.set(columnVisibility);
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
let thClass = $derived(compact ? "px-4 py-2 h-9" : "px-6 py-3 h-12");
let tdClass = $derived(compact ? "px-4 py-2" : "px-6 py-4");
</script>
<Card.Root class="shrink-0 gap-0 overflow-hidden py-0 {cardClass}">
<Card.Header class="flex items-center justify-between border-b px-4 py-2">
<div class="flex items-center gap-2">
{#if title}
<Card.Title class="text-sm font-semibold">
{title}
<span class="text-muted-foreground text-xs font-normal">({metrics.length})</span>
</Card.Title>
{/if}
</div>
<div class="flex items-center gap-2">
{#if showPagination}
<span class="text-muted-foreground text-xs">Rows</span>
<Select.Root
type="single"
value={String(pagination.pageSize)}
onValueChange={(v) => table.setPageSize(Number(v))}
>
<Select.Trigger size="sm" class="h-7 w-[4.5rem] text-xs">
{pagination.pageSize}
</Select.Trigger>
<Select.Content>
{#each [5, 10, 25, 50] as size (size)}
<Select.Item value={String(size)}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="hover:bg-muted inline-flex size-7 items-center justify-center rounded-[min(var(--radius-md),12px)]"
title="Select columns"
>
<Columns3 class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="min-w-[16rem] p-0">
<DropdownMenu.Label class="text-muted-foreground border-b px-3 py-2 text-xs font-medium uppercase tracking-wider">
Columns
</DropdownMenu.Label>
{#each table.getAllColumns() as column (column.id)}
{#if column.getCanHide()}
<DropdownMenu.CheckboxItem
checked={column.getIsVisible()}
onCheckedChange={(v) => column.toggleVisibility(!!v)}
closeOnSelect={false}
>
{columnLabelMap[column.id] ?? column.id}
</DropdownMenu.CheckboxItem>
{/if}
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</Card.Header>
<Card.Content class="overflow-x-auto p-0">
<Table.Root class="min-w-full">
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head class={thClass} colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell class={tdClass}>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="text-muted-foreground py-6 text-center text-sm">
{emptyMessage}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{#if showPagination && metrics.length > 0}
<div class="flex items-center justify-between gap-2 border-t px-4 py-2 text-sm">
<span class="text-muted-foreground text-xs">
Page {pagination.pageIndex + 1} of {table.getPageCount()} · {metrics.length} total
</span>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onclick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
title="First page"
>
<ChevronsLeft />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
title="Previous page"
>
<ChevronLeft />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
title="Next page"
>
<ChevronRight />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
title="Last page"
>
<ChevronsRight />
</Button>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
<CaptureDialog capture={selectedCapture} open={dialogOpen} onclose={closeDialog} />
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { link } from "svelte-spa-router";
import { House, Boxes, Activity, ScrollText, Gauge, Sun, Moon, Monitor, ChevronRight, Settings } from "@lucide/svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { toggleTheme, themeMode, appTitle } from "../stores/theme";
import { currentRoute } from "../stores/route";
import { playgroundActivity } from "../stores/playgroundActivity";
import { performanceEnabled, models } from "../stores/api";
import { selectedPlaygroundTab, playgroundTabs, playgroundMenuOpen } from "../stores/playground";
import { modelsMenuOpen } from "../stores/sidebar";
import type { Model } from "../lib/types";
import ConnectionStatus from "./ConnectionStatus.svelte";
function handleTitleChange(newTitle: string): void {
const sanitized = newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap";
appTitle.set(sanitized);
}
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === "Enter") {
e.preventDefault();
const target = e.currentTarget as HTMLElement;
handleTitleChange(target.textContent || "(set title)");
target.blur();
}
}
function handleBlur(e: FocusEvent): void {
const target = e.currentTarget as HTMLElement;
handleTitleChange(target.textContent || "(set title)");
}
function isActive(path: string, current: string): boolean {
return path === "/" ? current === "/" : current.startsWith(path);
}
type DotColor = "grey" | "yellow" | "green";
function statusDotColor(model: Model): DotColor {
if (model.state === "ready") return "green";
if (model.state === "starting" || model.state === "stopping") return "yellow";
return "grey";
}
const dotClass: Record<DotColor, string> = {
grey: "bg-muted-foreground/40",
yellow: "bg-warning",
green: "bg-success",
};
</script>
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex shrink-0 items-center justify-center">
<ConnectionStatus />
</div>
<h1
contenteditable="true"
class="truncate pb-0 text-base font-semibold outline-none rounded-md px-1 hover:bg-sidebar-accent group-data-[collapsible=icon]:hidden"
onblur={handleBlur}
onkeydown={handleKeyDown}
>
{$appTitle}
</h1>
</div>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupContent>
<Sidebar.Menu class="gap-1">
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive("/activity", $currentRoute)} tooltipContent="Activity">
{#snippet child({ props })}
<a href="/activity" use:link {...props}>
<Activity />
<span>Activity</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Collapsible.Root
open={$playgroundMenuOpen}
onOpenChange={(v) => playgroundMenuOpen.set(v)}
class="gap-0"
>
<Sidebar.MenuButton
isActive={isActive("/", $currentRoute)}
tooltipContent="Playground"
>
{#snippet child({ props })}
<a href="/" use:link {...props}>
<House />
<span class={$playgroundActivity ? "activity-link" : ""}>Playground</span>
<span
class="ml-auto transition-transform duration-200 {$playgroundMenuOpen ? 'rotate-90' : ''}"
role="button"
tabindex="0"
aria-label="Toggle playground section"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
playgroundMenuOpen.update((v) => !v);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
playgroundMenuOpen.update((v) => !v);
}
}}
>
<ChevronRight />
</span>
</a>
{/snippet}
</Sidebar.MenuButton>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each playgroundTabs as tab (tab.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={isActive("/", $currentRoute) && $selectedPlaygroundTab === tab.id}
>
{#snippet child({ props })}
<a
href="/"
use:link
{...props}
onclick={() => selectedPlaygroundTab.set(tab.id)}
>
<span>{tab.label}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Collapsible.Root
open={$modelsMenuOpen}
onOpenChange={(v) => modelsMenuOpen.set(v)}
class="gap-0"
>
<Sidebar.MenuButton
isActive={$currentRoute.startsWith("/models")}
tooltipContent="Models"
>
{#snippet child({ props })}
<a href="/models" use:link {...props}>
<Boxes />
<span>Models</span>
<span
class="ml-auto transition-transform duration-200 {$modelsMenuOpen ? 'rotate-90' : ''}"
role="button"
tabindex="0"
aria-label="Toggle models section"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
modelsMenuOpen.update((v) => !v);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
modelsMenuOpen.update((v) => !v);
}
}}
>
<ChevronRight />
</span>
</a>
{/snippet}
</Sidebar.MenuButton>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each $models as model (model.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={$currentRoute === `/models/${encodeURIComponent(model.id)}`}
>
{#snippet child({ props })}
<a href="/models/{encodeURIComponent(model.id)}" use:link {...props}>
<span class={`size-2 shrink-0 rounded-full ${dotClass[statusDotColor(model)]}`}></span>
<span class="flex-1 truncate">{model.id}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive("/logs", $currentRoute)} tooltipContent="Logs">
{#snippet child({ props })}
<a href="/logs" use:link {...props}>
<ScrollText />
<span>Logs</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{#if $performanceEnabled}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive("/performance", $currentRoute)} tooltipContent="Performance">
{#snippet child({ props })}
<a href="/performance" use:link {...props}>
<Gauge />
<span>Performance</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<div
class="flex items-center justify-between gap-2 px-1 group-data-[collapsible=icon]:flex-col-reverse"
>
<Sidebar.MenuButton
isActive={isActive("/settings", $currentRoute)}
tooltipContent="Settings"
>
{#snippet child({ props })}
<a href="/settings" use:link {...props}>
<Settings />
<span>Settings</span>
</a>
{/snippet}
</Sidebar.MenuButton>
<Button
variant="ghost"
size="icon"
onclick={toggleTheme}
title="Toggle theme (current: {$themeMode})"
>
{#if $themeMode === "system"}
<Monitor />
{:else if $themeMode === "light"}
<Sun />
{:else}
<Moon />
{/if}
<span class="sr-only">Toggle theme</span>
</Button>
</div>
</Sidebar.Footer>
<Sidebar.Rail />
</Sidebar.Root>
+48 -64
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { ReqRespCapture } from "../lib/types"; import type { ReqRespCapture } from "../lib/types";
import { Button } from "$lib/components/ui/button/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
interface Props { interface Props {
capture: ReqRespCapture | null; capture: ReqRespCapture | null;
@@ -9,22 +11,12 @@
let { capture, open, onclose }: Props = $props(); let { capture, open, onclose }: Props = $props();
let dialogEl: HTMLDialogElement | undefined = $state();
type BodyTab = "raw" | "pretty" | "chat"; type BodyTab = "raw" | "pretty" | "chat";
let reqBodyTab: BodyTab = $state("pretty"); let reqBodyTab: BodyTab = $state("pretty");
let respBodyTab: BodyTab = $state("pretty"); let respBodyTab: BodyTab = $state("pretty");
let copiedReq = $state(false); let copiedReq = $state(false);
let copiedResp = $state(false); let copiedResp = $state(false);
$effect(() => {
if (open && dialogEl) {
dialogEl.showModal();
} else if (!open && dialogEl) {
dialogEl.close();
}
});
// Reset tabs when capture changes // Reset tabs when capture changes
$effect(() => { $effect(() => {
if (capture) { if (capture) {
@@ -39,10 +31,6 @@
} }
}); });
function handleDialogClose() {
onclose();
}
function decodeBody(body: string | null | undefined): string { function decodeBody(body: string | null | undefined): string {
if (!body) return ""; if (!body) return "";
try { try {
@@ -190,40 +178,36 @@
}); });
</script> </script>
<dialog <Dialog.Root
bind:this={dialogEl} {open}
onclose={handleDialogClose} onOpenChange={(v) => {
class="bg-surface text-txtmain rounded-lg shadow-xl max-w-[80%] w-full max-h-[90vh] p-0 backdrop:bg-black/50 m-auto" if (!v) onclose();
}}
> >
<Dialog.Content class="flex max-h-[90vh] w-[90%] sm:max-w-[90%] flex-col gap-0 p-0">
{#if capture} {#if capture}
<div class="flex flex-col max-h-[90vh]"> <Dialog.Header class="border-b border-border px-4 py-3">
<div <Dialog.Title class="text-lg font-bold">
class="flex justify-between items-center p-4 border-b border-card-border" Capture #{capture.id + 1}{#if capture.req_path}
> <span class="font-mono text-base font-normal text-muted-foreground">{capture.req_path}</span>{/if}
<h2 class="text-xl font-bold pb-0">Capture #{capture.id + 1}{#if capture.req_path} <span class="text-base font-mono font-normal text-txtsecondary">{capture.req_path}</span>{/if}</h2> </Dialog.Title>
<button </Dialog.Header>
onclick={() => dialogEl?.close()}
class="text-txtsecondary hover:text-txtmain text-2xl leading-none"
>
&times;
</button>
</div>
<div class="overflow-y-auto flex-1 p-4 space-y-4"> <div class="min-h-0 flex-1 overflow-y-auto space-y-4 p-4">
<!-- Request Headers --> <!-- Request Headers -->
<details class="group" open> <details class="group" open>
<summary <summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-muted-foreground hover:text-foreground"
> >
Request Headers Request Headers
</summary> </summary>
<div <div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-48" class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-48"
> >
<table class="w-full text-sm"> <table class="w-full text-sm">
<tbody> <tbody>
{#each Object.entries(capture.req_headers || {}) as [key, value]} {#each Object.entries(capture.req_headers || {}) as [key, value]}
<tr class="border-b border-card-border-inner last:border-0"> <tr class="border-b border-border last:border-0">
<td class="px-3 py-1 font-mono text-primary whitespace-nowrap" <td class="px-3 py-1 font-mono text-primary whitespace-nowrap"
>{key}</td >{key}</td
> >
@@ -238,7 +222,7 @@
<!-- Request Body --> <!-- Request Body -->
<details class="group" open> <details class="group" open>
<summary <summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-muted-foreground hover:text-foreground"
> >
Request Body Request Body
</summary> </summary>
@@ -271,14 +255,14 @@
</button> </button>
</div> </div>
<div <div
class="mt-1 bg-background rounded border border-card-border overflow-auto max-h-96" class="mt-1 bg-background rounded-md border border-border overflow-auto max-h-96"
> >
<pre <pre
class="p-3 text-sm font-mono whitespace-pre-wrap break-all">{displayedRequestBody}</pre> class="p-3 text-sm font-mono whitespace-pre-wrap break-all">{displayedRequestBody}</pre>
</div> </div>
{:else} {:else}
<div <div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-96" class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96"
> >
<pre class="p-3 text-sm font-mono whitespace-pre-wrap break-all" <pre class="p-3 text-sm font-mono whitespace-pre-wrap break-all"
>(empty)</pre >(empty)</pre
@@ -290,17 +274,17 @@
<!-- Response Headers --> <!-- Response Headers -->
<details class="group" open> <details class="group" open>
<summary <summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-muted-foreground hover:text-foreground"
> >
Response Headers Response Headers
</summary> </summary>
<div <div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-48" class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-48"
> >
<table class="w-full text-sm"> <table class="w-full text-sm">
<tbody> <tbody>
{#each Object.entries(capture.resp_headers || {}) as [key, value]} {#each Object.entries(capture.resp_headers || {}) as [key, value]}
<tr class="border-b border-card-border-inner last:border-0"> <tr class="border-b border-border last:border-0">
<td class="px-3 py-1 font-mono text-primary whitespace-nowrap" <td class="px-3 py-1 font-mono text-primary whitespace-nowrap"
>{key}</td >{key}</td
> >
@@ -315,13 +299,13 @@
<!-- Response Body --> <!-- Response Body -->
<details class="group" open> <details class="group" open>
<summary <summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-muted-foreground hover:text-foreground"
> >
Response Body Response Body
</summary> </summary>
{#if isResponseImage && capture.resp_body} {#if isResponseImage && capture.resp_body}
<div <div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-96" class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96"
> >
<div class="p-3 flex justify-center"> <div class="p-3 flex justify-center">
<img <img
@@ -368,26 +352,26 @@
</button> </button>
</div> </div>
<div <div
class="mt-1 bg-background rounded border border-card-border overflow-auto max-h-96" class="mt-1 bg-background rounded-md border border-border overflow-auto max-h-96"
> >
{#if respBodyTab === "chat"} {#if respBodyTab === "chat"}
<div class="p-3 text-sm space-y-3"> <div class="p-3 text-sm space-y-3">
{#if sseChat.reasoning} {#if sseChat.reasoning}
<div> <div>
<div <div
class="text-xs font-semibold uppercase tracking-wider text-txtsecondary mb-1" class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1"
> >
Reasoning Reasoning
</div> </div>
<pre <pre
class="font-mono whitespace-pre-wrap break-all text-txtsecondary">{sseChat.reasoning}</pre> class="font-mono whitespace-pre-wrap break-all text-muted-foreground">{sseChat.reasoning}</pre>
</div> </div>
{/if} {/if}
{#if sseChat.content} {#if sseChat.content}
<div> <div>
{#if sseChat.reasoning} {#if sseChat.reasoning}
<div <div
class="text-xs font-semibold uppercase tracking-wider text-txtsecondary mb-1" class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1"
> >
Response Response
</div> </div>
@@ -407,15 +391,15 @@
</div> </div>
{:else if responseBodyRaw} {:else if responseBodyRaw}
<div <div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-96" class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96"
> >
<div class="p-3 text-sm text-txtsecondary italic"> <div class="p-3 text-sm text-muted-foreground italic">
(binary data - {responseContentType || "unknown content type"}) (binary data - {responseContentType || "unknown content type"})
</div> </div>
</div> </div>
{:else} {:else}
<div <div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-96" class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96"
> >
<pre class="p-3 text-sm font-mono">(empty)</pre> <pre class="p-3 text-sm font-mono">(empty)</pre>
</div> </div>
@@ -423,39 +407,39 @@
</details> </details>
</div> </div>
<div class="p-4 border-t border-card-border flex justify-end"> <Dialog.Footer class="border-t border-border px-4 py-3 sm:justify-end">
<button onclick={() => dialogEl?.close()} class="btn"> Close </button> <Button variant="outline" onclick={onclose}>Close</Button>
</div> </Dialog.Footer>
</div>
{:else} {:else}
<div class="flex flex-col items-center justify-center p-12"> <div class="flex flex-col items-center justify-center p-12">
<p class="text-lg text-txtsecondary">Capture not found</p> <p class="text-lg text-muted-foreground">Capture not found</p>
<p class="text-sm text-txtsecondary mt-1">The capture may have expired or was never recorded.</p> <p class="text-sm text-muted-foreground mt-1">The capture may have expired or was never recorded.</p>
<div class="mt-4"> <div class="mt-4">
<button onclick={() => dialogEl?.close()} class="btn">Close</button> <Button variant="outline" onclick={onclose}>Close</Button>
</div> </div>
</div> </div>
{/if} {/if}
</dialog> </Dialog.Content>
</Dialog.Root>
<style> <style>
.tab-btn { .tab-btn {
padding: 2px 10px; padding: 2px 10px;
font-size: 0.75rem; font-size: 0.75rem;
border-radius: 4px; border-radius: 0;
color: var(--color-txtsecondary); color: var(--muted-foreground);
cursor: pointer; cursor: pointer;
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
transition: all 0.15s; transition: all 0.15s;
} }
.tab-btn:hover { .tab-btn:hover {
color: var(--color-txtmain); color: var(--foreground);
background: var(--color-secondary); background: var(--accent);
} }
.tab-btn-active { .tab-btn-active {
color: var(--color-primary); color: var(--primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent); background: color-mix(in srgb, var(--primary) 12%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 25%, transparent); border-color: color-mix(in srgb, var(--primary) 25%, transparent);
} }
</style> </style>
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { connectionState } from "../stores/theme"; import { connectionState } from "../stores/theme";
import { versionInfo } from "../stores/api";
let eventStatusColor = $derived.by(() => { let eventStatusColor = $derived.by(() => {
switch ($connectionState) { switch ($connectionState) {
@@ -14,9 +13,7 @@
} }
}); });
let tooltipText = $derived( let tooltipText = $derived(`Event Stream: ${$connectionState ?? "unknown"}`);
`Event Stream: ${$connectionState ?? "unknown"}\nAPI Version: ${$versionInfo?.version ?? "unknown"}\nCommit Hash: ${$versionInfo?.commit?.substring(0, 7) ?? "unknown"}\nBuild Date: ${$versionInfo?.build_date ?? "unknown"}`
);
</script> </script>
<div class="flex items-center" title={tooltipText}> <div class="flex items-center" title={tooltipText}>
-144
View File
@@ -1,144 +0,0 @@
<script lang="ts">
import { link } from "svelte-spa-router";
import { screenWidth, toggleTheme, themeMode, appTitle, isNarrow } from "../stores/theme";
import { currentRoute } from "../stores/route";
import { playgroundActivity } from "../stores/playgroundActivity";
import { performanceEnabled } from "../stores/api";
import ConnectionStatus from "./ConnectionStatus.svelte";
function handleTitleChange(newTitle: string): void {
const sanitized = newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap";
appTitle.set(sanitized);
}
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === "Enter") {
e.preventDefault();
const target = e.currentTarget as HTMLElement;
handleTitleChange(target.textContent || "(set title)");
target.blur();
}
}
function handleBlur(e: FocusEvent): void {
const target = e.currentTarget as HTMLElement;
handleTitleChange(target.textContent || "(set title)");
}
function isActive(path: string, current: string): boolean {
return path === "/" ? current === "/" : current.startsWith(path);
}
</script>
<header
class="flex items-center justify-between bg-surface border-b border-border px-4 {$isNarrow
? 'py-1 h-[60px]'
: 'p-2 h-[75px]'}"
>
{#if $screenWidth !== "xs" && $screenWidth !== "sm"}
<h1
contenteditable="true"
class="p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
onblur={handleBlur}
onkeydown={handleKeyDown}
>
{$appTitle}
</h1>
{/if}
<menu class="flex items-center gap-4 overflow-x-auto">
<a
href="/"
use:link
class="p-1 whitespace-nowrap {isActive('/', $currentRoute) ? 'font-semibold underline underline-offset-4' : ''} {$playgroundActivity ? 'activity-link' : 'text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100'}"
>
Playground
</a>
<a
href="/models"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/models", $currentRoute)}
class:underline={isActive("/models", $currentRoute)}
class:underline-offset-4={isActive("/models", $currentRoute)}
>
Models
</a>
<a
href="/activity"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/activity", $currentRoute)}
class:underline={isActive("/activity", $currentRoute)}
class:underline-offset-4={isActive("/activity", $currentRoute)}
>
Activity
</a>
<a
href="/logs"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/logs", $currentRoute)}
class:underline={isActive("/logs", $currentRoute)}
class:underline-offset-4={isActive("/logs", $currentRoute)}
>
Logs
</a>
{#if $performanceEnabled}
<a
href="/performance"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/performance", $currentRoute)}
class:underline={isActive("/performance", $currentRoute)}
class:underline-offset-4={isActive("/performance", $currentRoute)}
>
Performance
</a>
{/if}
<button onclick={toggleTheme} title="Toggle theme (current: {$themeMode})">
{#if $themeMode === "system"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path d="M0,9c0-.552,.448-1,1-1H3.108c.147-.874,.472-1.721,1.006-2.471l-1.478-1.478c-.391-.391-.391-1.023,0-1.414s1.023-.391,1.414,0l1.478,1.478c.751-.534,1.598-.859,2.471-1.006V1c0-.552,.448-1,1-1s1,.448,1,1V3.108c.874,.147,1.725,.466,2.477,1.001l1.473-1.473c.391-.391,1.023-.391,1.414,0s.391,1.023,0,1.414L3.963,15.45c-.195,.195-.451,.293-.707,.293s-.512-.098-.707-.293c-.391-.391-.391-1.023,0-1.414l1.56-1.56c-.535-.751-.854-1.602-1.001-2.477H1c-.552,0-1-.448-1-1ZM23.707,.293c-.391-.391-1.023-.391-1.414,0L.293,22.293c-.391,.391-.391,1.023,0,1.414,.195,.195,.451,.293,.707,.293s.512-.098,.707-.293L23.707,1.707c.391-.391,.391-1.023,0-1.414Zm-.283,10.954c.32-.15,.538-.458,.572-.81,.034-.353-.121-.696-.407-.904-.858-.625-1.833-1.066-2.897-1.315-.335-.078-.69,.022-.934,.267l-8.392,8.391c-.244,.244-.345,.597-.267,.933,.843,3.646,4.047,6.191,7.792,6.191,1.695,0,3.32-.53,4.697-1.533,.286-.208,.441-.553,.407-.904-.034-.353-.251-.66-.572-.811-1.842-.861-3.033-2.727-3.033-4.752s1.19-3.891,3.033-4.753Z"/>
</svg>
{:else if $themeMode === "light"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path
fill-rule="evenodd"
d="M12 2.25a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM7.5 12a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM18.894 6.166a.75.75 0 0 0-1.06-1.06l-1.591 1.59a.75.75 0 1 0 1.06 1.061l1.591-1.59ZM21.75 12a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1 0-1.5H21a.75.75 0 0 1 .75.75ZM17.834 18.894a.75.75 0 0 0 1.06-1.06l-1.59-1.591a.75.75 0 1 0-1.061 1.06l1.591 1.591ZM12 18a.75.75 0 0 1 .75.75V21a.75.75 0 0 1-1.5 0v-2.25A.75.75 0 0 1 12 18ZM7.758 17.303a.75.75 0 0 0-1.061-1.06l-1.591 1.59a.75.75 0 0 0 1.06 1.061l1.591-1.59ZM6 12a.75.75 0 0 1-.75.75H3a.75.75 0 0 1 0-1.5h2.25A.75.75 0 0 1 6 12ZM6.697 7.757a.75.75 0 0 0 1.06-1.06l-1.59-1.591a.75.75 0 0 0-1.061 1.06l1.59 1.591Z"
/>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path
fill-rule="evenodd"
d="M9.528 1.718a.75.75 0 0 1 .162.819A8.97 8.97 0 0 0 9 6a9 9 0 0 0 9 9 8.97 8.97 0 0 0 3.463-.69.75.75 0 0 1 .981.98 10.503 10.503 0 0 1-9.694 6.46c-5.799 0-10.5-4.7-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 0 1 .818.162Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
<ConnectionStatus />
</menu>
</header>
<style>
.activity-link {
background: linear-gradient(90deg, #6366f1, #8b5cf6, #a855f7, #8b5cf6, #6366f1);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 2s linear infinite;
}
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
</style>
+29 -52
View File
@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
import { persistentStore } from "../stores/persistent"; import { persistentStore } from "../stores/persistent";
import { Type, WrapText, Search, SearchX, CircleX } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import * as Card from "$lib/components/ui/card/index.js";
interface Props { interface Props {
id: string; id: string;
@@ -81,59 +85,32 @@
}); });
</script> </script>
<div class="rounded-lg overflow-hidden flex flex-col bg-gray-950/5 dark:bg-white/10 h-full w-full p-1"> <Card.Root class="bg-muted/30 h-full w-full gap-0 overflow-hidden py-0">
<div class="p-4"> <Card.Header class="border-b px-4 py-2">
<div class="flex items-center justify-between"> <Card.Title class="text-sm font-semibold">{title}</Card.Title>
<h3 class="m-0 text-lg p-0">{title}</h3> <Card.Action>
<div class="flex items-center gap-1">
<div class="flex gap-2 items-center"> <Button variant="ghost" size="icon-sm" onclick={toggleFontSize} title="Change font size">
<button class="btn border-0" onclick={toggleFontSize} title="Change font size"> <Type />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4"> </Button>
<path d="M2 4v3h5v12h3V7h5V4H2zm19 5h-9v3h3v7h3v-7h3V9z"/> <Button variant="ghost" size="icon-sm" onclick={toggleWrapText} title="Toggle text wrap">
</svg> <WrapText class={$wrapTextStore ? "text-primary" : ""} />
</button> </Button>
<button class="btn border-0" onclick={toggleWrapText} title="Toggle text wrap"> <Button variant="ghost" size="icon-sm" onclick={toggleFilter} title="Toggle filter">
{#if $wrapTextStore} {#if $showFilterStore}<SearchX />{:else}<Search />{/if}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4"> </Button>
<path fill-rule="evenodd" d="M3 6.75A.75.75 0 0 1 3.75 6h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 6.75ZM3 12a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 12Zm0 5.25a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" /> </div>
</svg> </Card.Action>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M3 6.75A.75.75 0 0 1 3.75 6h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 6.75ZM3 12a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 12Zm0 5.25a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
</svg>
{/if}
</button>
<button class="btn border-0" onclick={toggleFilter} title="Toggle filter">
{#if $showFilterStore} {#if $showFilterStore}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4"> <div class="flex w-full items-center gap-2 pt-2">
<path fill-rule="evenodd" d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z" clip-rule="evenodd" /> <Input type="text" class="h-8" placeholder="Filter logs (regex)..." bind:value={filterRegex} />
</svg> <Button variant="ghost" size="icon-sm" onclick={() => (filterRegex = "")} aria-label="Clear filter">
{:else} <CircleX />
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> </Button>
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
{/if}
</button>
</div>
</div>
{#if $showFilterStore}
<div class="mt-2 flex gap-2 items-center w-full">
<input
type="text"
class="w-full text-sm border border-gray-950/10 dark:border-white/5 p-2 rounded outline-none"
placeholder="Filter logs (regex)..."
bind:value={filterRegex}
/>
<button class="pl-2" onclick={() => (filterRegex = "")} aria-label="Clear filter">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z" clip-rule="evenodd" />
</svg>
</button>
</div> </div>
{/if} {/if}
</div> </Card.Header>
<div class="rounded-lg bg-background font-mono text-sm flex-1 overflow-hidden"> <Card.Content class="bg-background min-h-0 flex-1 p-0 font-mono text-sm">
<pre bind:this={preElement} onscroll={handleScroll} class="{textWrapClass} {fontSizeClass} h-full overflow-auto p-4">{filteredLogs}</pre> <pre bind:this={preElement} onscroll={handleScroll} class="{textWrapClass} {fontSizeClass} h-full overflow-auto p-4">{filteredLogs}</pre>
</div> </Card.Content>
</div> </Card.Root>
+12 -60
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props { interface Props {
metadata: Record<string, string> | undefined; metadata: Record<string, string> | undefined;
@@ -9,68 +10,15 @@
let { metadata, children }: Props = $props(); let { metadata, children }: Props = $props();
let entries = $derived(Object.entries(metadata || {})); let entries = $derived(Object.entries(metadata || {}));
let triggerEl: HTMLElement | undefined = $state();
let tooltipEl: HTMLDivElement | undefined = $state();
let show = $state(false);
let tooltipStyle = $state("");
function positionTooltip() {
if (!triggerEl || !tooltipEl) return;
const triggerRect = triggerEl.getBoundingClientRect();
const tooltipRect = tooltipEl.getBoundingClientRect();
const margin = 8;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = triggerRect.left;
let top = triggerRect.bottom + margin;
// Keep tooltip within horizontal viewport bounds
if (left + tooltipRect.width > viewportWidth - margin) {
left = triggerRect.right - tooltipRect.width;
}
if (left < margin) {
left = margin;
}
// Flip above trigger if it would overflow the bottom
if (top + tooltipRect.height > viewportHeight - margin) {
top = triggerRect.top - tooltipRect.height - margin;
}
tooltipStyle = `left: ${left}px; top: ${top}px; max-width: calc(100vw - ${margin * 2}px);`;
}
function onEnter() {
show = true;
requestAnimationFrame(positionTooltip);
}
function onLeave() {
show = false;
}
</script> </script>
<span {#if entries.length > 0}
bind:this={triggerEl} <Tooltip.Provider>
onmouseenter={onEnter} <Tooltip.Root>
onmouseleave={onLeave} <Tooltip.Trigger>
onfocus={onEnter}
onblur={onLeave}
class="inline-flex"
role="button"
tabindex="0"
aria-label="Show metadata"
>
{@render children()} {@render children()}
</span> </Tooltip.Trigger>
<Tooltip.Content class="min-w-[12rem] max-w-[24rem] normal-case">
{#if show && entries.length > 0}
<div
bind:this={tooltipEl}
style={tooltipStyle}
class="fixed px-3 py-2 bg-gray-900 text-white text-sm rounded-md z-50 normal-case min-w-[12rem] max-w-[24rem] shadow-lg whitespace-normal"
>
<table class="w-full text-left"> <table class="w-full text-left">
<tbody> <tbody>
{#each entries as [key, value]} {#each entries as [key, value]}
@@ -81,5 +29,9 @@
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{:else}
{@render children()}
{/if} {/if}
-238
View File
@@ -1,238 +0,0 @@
<script lang="ts">
import { models, loadModel, unloadAllModels, unloadSingleModel } from "../stores/api";
import { isNarrow } from "../stores/theme";
import { persistentStore } from "../stores/persistent";
import type { Model } from "../lib/types";
let isUnloading = $state(false);
let menuOpen = $state(false);
let pendingLoads = $state<Record<string, boolean>>({});
const loadControllers = new Map<string, AbortController>();
const showUnlistedStore = persistentStore<boolean>("showUnlisted", true);
const showIdorNameStore = persistentStore<"id" | "name">("showIdorName", "id");
let filteredModels = $derived.by(() => {
const filtered = $models.filter((model) => $showUnlistedStore || !model.unlisted);
const peerModels = filtered.filter((m) => m.peerID);
// Group peer models by peerID
const grouped = peerModels.reduce(
(acc, model) => {
const peerId = model.peerID || "unknown";
if (!acc[peerId]) acc[peerId] = [];
acc[peerId].push(model);
return acc;
},
{} as Record<string, Model[]>
);
return {
regularModels: filtered.filter((m) => !m.peerID),
peerModelsByPeerId: grouped,
};
});
async function handleUnloadAllModels(): Promise<void> {
isUnloading = true;
try {
await unloadAllModels();
} catch (e) {
console.error(e);
} finally {
setTimeout(() => (isUnloading = false), 1000);
}
}
async function handleLoadModel(modelId: string): Promise<void> {
if (pendingLoads[modelId]) return;
const controller = new AbortController();
loadControllers.set(modelId, controller);
pendingLoads[modelId] = true;
try {
await loadModel(modelId, controller.signal);
} catch (e) {
console.error(e);
} finally {
loadControllers.delete(modelId);
delete pendingLoads[modelId];
}
}
function cancelLoad(modelId: string): void {
loadControllers.get(modelId)?.abort();
}
function toggleIdorName(): void {
showIdorNameStore.update((prev) => (prev === "name" ? "id" : "name"));
}
function toggleShowUnlisted(): void {
showUnlistedStore.update((prev) => !prev);
}
function getModelDisplay(model: Model): string {
return $showIdorNameStore === "id" ? model.id : (model.name || model.id);
}
</script>
<div class="card h-full flex flex-col">
<div class="shrink-0">
<div class="flex justify-between items-baseline">
<h2 class={$isNarrow ? "text-xl" : ""}>Models</h2>
{#if $isNarrow}
<div class="relative">
<button class="btn text-base flex items-center gap-2 py-1" onclick={() => (menuOpen = !menuOpen)} aria-label="Toggle menu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M3 6.75A.75.75 0 0 1 3.75 6h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 6.75ZM3 12a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 12Zm0 5.25a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
</svg>
</button>
{#if menuOpen}
<div class="absolute right-0 mt-2 w-48 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-20">
<button
class="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
onclick={() => { toggleIdorName(); menuOpen = false; }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M15.97 2.47a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 1 1-1.06-1.06l3.22-3.22H7.5a.75.75 0 0 1 0-1.5h11.69l-3.22-3.22a.75.75 0 0 1 0-1.06Zm-7.94 9a.75.75 0 0 1 0 1.06l-3.22 3.22H16.5a.75.75 0 0 1 0 1.5H4.81l3.22 3.22a.75.75 0 1 1-1.06 1.06l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
{$showIdorNameStore === "id" ? "Show Name" : "Show ID"}
</button>
<button
class="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
onclick={() => { toggleShowUnlisted(); menuOpen = false; }}
>
{#if $showUnlistedStore}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path d="M3.53 2.47a.75.75 0 0 0-1.06 1.06l18 18a.75.75 0 1 0 1.06-1.06l-18-18ZM22.676 12.553a11.249 11.249 0 0 1-2.631 4.31l-3.099-3.099a5.25 5.25 0 0 0-6.71-6.71L7.759 4.577a11.217 11.217 0 0 1 4.242-.827c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113Z" />
<path d="M15.75 12c0 .18-.013.357-.037.53l-4.244-4.243A3.75 3.75 0 0 1 15.75 12ZM12.53 15.713l-4.243-4.244a3.75 3.75 0 0 0 4.244 4.243Z" />
<path d="M6.75 12c0-.619.107-1.213.304-1.764l-3.1-3.1a11.25 11.25 0 0 0-2.63 4.31c-.12.362-.12.752 0 1.114 1.489 4.467 5.704 7.69 10.675 7.69 1.5 0 2.933-.294 4.242-.827l-2.477-2.477A5.25 5.25 0 0 1 6.75 12Z" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<path fill-rule="evenodd" d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 0 1 0-1.113ZM17.25 12a5.25 5.25 0 1 1-10.5 0 5.25 5.25 0 0 1 10.5 0Z" clip-rule="evenodd" />
</svg>
{/if}
{$showUnlistedStore ? "Hide Unlisted" : "Show Unlisted"}
</button>
<button
class="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
onclick={() => { handleUnloadAllModels(); menuOpen = false; }}
disabled={isUnloading}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm.53 5.47a.75.75 0 0 0-1.06 0l-3 3a.75.75 0 1 0 1.06 1.06l1.72-1.72v5.69a.75.75 0 0 0 1.5 0v-5.69l1.72 1.72a.75.75 0 1 0 1.06-1.06l-3-3Z" clip-rule="evenodd" />
</svg>
{isUnloading ? "Unloading..." : "Unload All"}
</button>
</div>
{/if}
</div>
{/if}
</div>
{#if !$isNarrow}
<div class="flex justify-between">
<div class="flex gap-2">
<button class="btn text-base flex items-center gap-2" onclick={toggleIdorName} style="line-height: 1.2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M15.97 2.47a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 1 1-1.06-1.06l3.22-3.22H7.5a.75.75 0 0 1 0-1.5h11.69l-3.22-3.22a.75.75 0 0 1 0-1.06Zm-7.94 9a.75.75 0 0 1 0 1.06l-3.22 3.22H16.5a.75.75 0 0 1 0 1.5H4.81l3.22 3.22a.75.75 0 1 1-1.06 1.06l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
{$showIdorNameStore === "id" ? "ID" : "Name"}
</button>
<button class="btn text-base flex items-center gap-2" onclick={toggleShowUnlisted} style="line-height: 1.2">
{#if $showUnlistedStore}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<path fill-rule="evenodd" d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 0 1 0-1.113ZM17.25 12a5.25 5.25 0 1 1-10.5 0 5.25 5.25 0 0 1 10.5 0Z" clip-rule="evenodd" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path d="M3.53 2.47a.75.75 0 0 0-1.06 1.06l18 18a.75.75 0 1 0 1.06-1.06l-18-18ZM22.676 12.553a11.249 11.249 0 0 1-2.631 4.31l-3.099-3.099a5.25 5.25 0 0 0-6.71-6.71L7.759 4.577a11.217 11.217 0 0 1 4.242-.827c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113Z" />
<path d="M15.75 12c0 .18-.013.357-.037.53l-4.244-4.243A3.75 3.75 0 0 1 15.75 12ZM12.53 15.713l-4.243-4.244a3.75 3.75 0 0 0 4.244 4.243Z" />
<path d="M6.75 12c0-.619.107-1.213.304-1.764l-3.1-3.1a11.25 11.25 0 0 0-2.63 4.31c-.12.362-.12.752 0 1.114 1.489 4.467 5.704 7.69 10.675 7.69 1.5 0 2.933-.294 4.242-.827l-2.477-2.477A5.25 5.25 0 0 1 6.75 12Z" />
</svg>
{/if}
unlisted
</button>
</div>
<button class="btn text-base flex items-center gap-2" onclick={handleUnloadAllModels} disabled={isUnloading}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm.53 5.47a.75.75 0 0 0-1.06 0l-3 3a.75.75 0 1 0 1.06 1.06l1.72-1.72v5.69a.75.75 0 0 0 1.5 0v-5.69l1.72 1.72a.75.75 0 1 0 1.06-1.06l-3-3Z" clip-rule="evenodd" />
</svg>
{isUnloading ? "Unloading..." : "Unload All"}
</button>
</div>
{/if}
</div>
<div class="flex-1 overflow-y-auto">
<table class="w-full">
<thead class="sticky top-0 bg-card z-10">
<tr class="text-left border-b border-gray-200 dark:border-white/10 bg-surface">
<th>{$showIdorNameStore === "id" ? "Model ID" : "Name"}</th>
<th></th>
<th>State</th>
</tr>
</thead>
<tbody>
{#each filteredModels.regularModels as model (model.id)}
<tr class="border-b hover:bg-secondary-hover border-gray-200">
<td class={model.unlisted ? "text-txtsecondary" : ""}>
<a href="/upstream/{model.id}/" class="font-semibold" target="_blank">
{getModelDisplay(model)}
</a>
{#if model.description}
<p class={model.unlisted ? "text-opacity-70" : ""}><em>{model.description}</em></p>
{/if}
{#if model.aliases && model.aliases.length > 0}
<p class="text-xs text-txtsecondary">Aliases: {model.aliases.join(", ")}</p>
{/if}
</td>
<td class="w-12">
{#if model.state === "stopped" && pendingLoads[model.id]}
<button class="btn btn--sm" onclick={() => cancelLoad(model.id)}>Cancel</button>
{:else if model.state === "stopped"}
<button class="btn btn--sm" onclick={() => handleLoadModel(model.id)}>Load</button>
{:else}
<button class="btn btn--sm" onclick={() => unloadSingleModel(model.id)} disabled={model.state !== "ready"}>Unload</button>
{/if}
</td>
<td class="w-20">
{#if model.state === "stopped" && pendingLoads[model.id]}
<span class="w-16 text-center status status--queued">queued</span>
{:else}
<span class="w-16 text-center status status--{model.state}">{model.state}</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{#if Object.keys(filteredModels.peerModelsByPeerId).length > 0}
<h3 class="mt-8 mb-2">Peer Models</h3>
{#each Object.entries(filteredModels.peerModelsByPeerId).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
<div class="mb-4">
<table class="w-full">
<thead class="sticky top-0 bg-card z-10">
<tr class="text-left border-b border-gray-200 dark:border-white/10 bg-surface">
<th class="font-semibold">{peerId}</th>
</tr>
</thead>
<tbody>
{#each peerModels as model (model.id)}
<tr class="border-b hover:bg-secondary-hover border-gray-200">
<td class="pl-8 {model.unlisted ? 'text-txtsecondary' : ''}">
<span>{model.id}</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
{/if}
</div>
</div>
@@ -2,6 +2,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { Chart, registerables } from "chart.js"; import { Chart, registerables } from "chart.js";
import { isDarkMode } from "../stores/theme"; import { isDarkMode } from "../stores/theme";
import * as Card from "$lib/components/ui/card/index.js";
Chart.register(...registerables); Chart.register(...registerables);
@@ -143,6 +144,8 @@
}); });
</script> </script>
<div class="card p-4 h-[300px]"> <Card.Root class="h-[300px] py-0">
<Card.Content class="h-full p-4">
<canvas bind:this={canvas}></canvas> <canvas bind:this={canvas}></canvas>
</div> </Card.Content>
</Card.Root>
@@ -135,7 +135,7 @@
<div <div
role="separator" role="separator"
tabindex="0" tabindex="0"
class="{handleClass} bg-primary hover:bg-success transition-colors rounded flex-shrink-0" class="{handleClass} bg-primary hover:bg-success transition-colors rounded-md flex-shrink-0"
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
ontouchstart={handleTouchStart} ontouchstart={handleTouchStart}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
+6 -12
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props { interface Props {
content: string; content: string;
} }
@@ -6,15 +8,7 @@
let { content }: Props = $props(); let { content }: Props = $props();
</script> </script>
<div class="relative group inline-block"> <Tooltip.Root>
<span class="cursor-help">&#9432;</span> <Tooltip.Trigger class="cursor-help align-middle normal-case">&#9432;</Tooltip.Trigger>
<div <Tooltip.Content>{content}</Tooltip.Content>
class="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 </Tooltip.Root>
px-3 py-2 bg-gray-900 text-white text-sm rounded-md
opacity-0 group-hover:opacity-100 transition-opacity
duration-200 pointer-events-none whitespace-nowrap z-50 normal-case"
>
{content}
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-b-gray-900"></div>
</div>
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props {
label: string;
tooltip?: string;
}
let { label, tooltip }: Props = $props();
</script>
{#if tooltip}
<Tooltip.Root>
<Tooltip.Trigger class="cursor-help border-b border-dotted border-current align-middle">
{label}
</Tooltip.Trigger>
<Tooltip.Content>{tooltip}</Tooltip.Content>
</Tooltip.Root>
{:else}
{label}
{/if}
@@ -0,0 +1,33 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props {
metadata: Record<string, string> | undefined;
}
let { metadata }: Props = $props();
let entries = $derived(Object.entries(metadata || {}));
</script>
{#if entries.length > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-muted-foreground hover:text-foreground cursor-help">...</span>
</Tooltip.Trigger>
<Tooltip.Content class="min-w-[12rem] max-w-[24rem] normal-case">
<table class="w-full text-left">
<tbody>
{#each entries as [key, value]}
<tr class="border-b border-white/10 last:border-0">
<td class="py-1 pr-3 font-medium whitespace-nowrap text-primary">{key}</td>
<td class="py-1 break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
interface Props {
hasCapture: boolean;
loading: boolean;
onclick: () => void;
}
let { hasCapture, loading, onclick }: Props = $props();
</script>
{#if hasCapture}
<Button variant="outline" size="xs" {onclick} disabled={loading}>
{loading ? "..." : "View"}
</Button>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
@@ -0,0 +1,24 @@
<script lang="ts">
import { metrics } from "../../stores/api";
import ActivityTable from "../ActivityTable.svelte";
interface Props {
modelId: string;
}
let { modelId }: Props = $props();
let modelMetrics = $derived(
[...$metrics].filter((m) => m.model === modelId).sort((a, b) => b.id - a.id)
);
</script>
<ActivityTable
metrics={modelMetrics}
storagePrefix="model-detail"
showModelColumn={false}
showPagination={true}
compact={true}
title="Recent Activity"
emptyMessage="No activity recorded for this model"
/>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Model } from "../../lib/types";
import * as Card from "$lib/components/ui/card/index.js";
interface Props {
model: Model;
}
let { model }: Props = $props();
const capabilityLabels: Record<string, string> = {
vision: "Vision",
audio_transcriptions: "Transcription",
audio_speech: "Speech",
image_generation: "Image Gen",
image_to_image: "Img→Img",
function_calling: "Function Calling",
reranker: "Reranker",
};
let capabilities = $derived.by(() => {
const caps = model?.capabilities ?? {};
return Object.entries(caps).filter(([, v]) => v);
});
</script>
<Card.Root class="shrink-0 gap-0 overflow-hidden py-0">
<Card.Header class="border-b px-4 py-2">
<Card.Title class="text-sm font-semibold">Capabilities</Card.Title>
</Card.Header>
<Card.Content class="p-3">
{#if capabilities.length === 0}
<span class="text-muted-foreground text-sm">No capabilities reported.</span>
{:else}
<div class="flex flex-wrap gap-1.5">
{#each capabilities as [key] (key)}
<span class="bg-muted text-muted-foreground rounded-md px-2 py-0.5 text-xs font-medium">
{capabilityLabels[key] ?? key}
</span>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,26 @@
<script lang="ts">
import { streamModelLog } from "../../stores/modelLogs";
import LogPanel from "../LogPanel.svelte";
interface Props {
modelId: string;
}
let { modelId }: Props = $props();
let logData = $state("");
$effect(() => {
const id = modelId;
if (!id) {
logData = "";
return;
}
const store = streamModelLog(id);
const unsub = store.subscribe((v) => (logData = v));
return () => unsub();
});
</script>
<div class="h-full">
<LogPanel id={`model-${modelId}`} title="Model Logs" {logData} />
</div>
@@ -4,6 +4,8 @@
import { transcribeAudio } from "../../lib/audioApi"; import { transcribeAudio } from "../../lib/audioApi";
import { playgroundStores } from "../../stores/playgroundActivity"; import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Copy, Check } from "@lucide/svelte";
const selectedModelStore = persistentStore<string>("playground-audio-model", ""); const selectedModelStore = persistentStore<string>("playground-audio-model", "");
@@ -150,14 +152,14 @@
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="flex-1 flex items-center justify-center text-muted-foreground">
<p>No models configured. Add models to your configuration to transcribe audio.</p> <p>No models configured. Add models to your configuration to transcribe audio.</p>
</div> </div>
{:else} {:else}
<!-- File upload / Result display area --> <!-- File upload / Result display area -->
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 rounded"> <div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-background border border-border rounded-md">
{#if isTranscribing} {#if isTranscribing}
<div class="text-center text-txtsecondary"> <div class="text-center text-muted-foreground">
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div> <div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
<p>Transcribing audio...</p> <p>Transcribing audio...</p>
</div> </div>
@@ -169,29 +171,26 @@
{:else if transcriptionResult} {:else if transcriptionResult}
<div class="w-full h-full flex flex-col p-4"> <div class="w-full h-full flex flex-col p-4">
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<h3 class="font-medium">Transcription Result</h3> <h3 class="pb-0 font-medium">Transcription Result</h3>
<button <Button
class="btn btn-sm" variant="outline"
size="icon-sm"
onclick={copyToClipboard} onclick={copyToClipboard}
title={copied ? 'Copied!' : 'Copy to clipboard'} title={copied ? 'Copied!' : 'Copy to clipboard'}
> >
{#if copied} {#if copied}
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Check class="text-success" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{:else} {:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Copy />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
{/if} {/if}
</button> </Button>
</div> </div>
<div class="flex-1 overflow-auto p-3 rounded border border-gray-200 dark:border-white/10 bg-background whitespace-pre-wrap"> <div class="flex-1 overflow-auto p-3 rounded-md border border-border bg-background whitespace-pre-wrap">
{transcriptionResult} {transcriptionResult}
</div> </div>
</div> </div>
{:else if selectedFile} {:else if selectedFile}
<div class="text-center text-txtsecondary p-4"> <div class="text-center text-muted-foreground p-4">
<p class="font-medium mb-2">File Selected</p> <p class="font-medium mb-2">File Selected</p>
<p class="text-sm">{selectedFile.name}</p> <p class="text-sm">{selectedFile.name}</p>
<p class="text-xs mt-1">{formatFileSize(selectedFile.size)}</p> <p class="text-xs mt-1">{formatFileSize(selectedFile.size)}</p>
@@ -200,7 +199,7 @@
<div <div
role="region" role="region"
aria-label="Audio file drop zone" aria-label="Audio file drop zone"
class="w-full h-full flex items-center justify-center text-center text-txtsecondary p-8 {isDragging ? 'bg-primary/10' : ''}" class="w-full h-full flex items-center justify-center text-center text-muted-foreground p-8 {isDragging ? 'bg-primary/10' : ''}"
ondragover={handleDragOver} ondragover={handleDragOver}
ondragleave={handleDragLeave} ondragleave={handleDragLeave}
ondrop={handleDrop} ondrop={handleDrop}
@@ -223,33 +222,21 @@
onchange={handleFileSelect} onchange={handleFileSelect}
bind:this={fileInput} bind:this={fileInput}
/> />
<button <Button variant="outline" onclick={() => fileInput?.click()} disabled={isTranscribing}>
class="btn"
onclick={() => fileInput?.click()}
disabled={isTranscribing}
>
Browse Files Browse Files
</button> </Button>
<div class="flex-1"></div> <div class="flex-1"></div>
{#if isTranscribing} {#if isTranscribing}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelTranscription}> <Button variant="destructive" onclick={cancelTranscription}>Cancel</Button>
Cancel
</button>
{:else} {:else}
<button <Button onclick={transcribe} disabled={!canTranscribe}>Transcribe</Button>
class="btn bg-primary text-btn-primary-text hover:opacity-90" <Button
onclick={transcribe} variant="outline"
disabled={!canTranscribe}
>
Transcribe
</button>
<button
class="btn"
onclick={clearAll} onclick={clearAll}
disabled={!selectedFile && !transcriptionResult && !error} disabled={!selectedFile && !transcriptionResult && !error}
> >
Clear Clear
</button> </Button>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -7,6 +7,14 @@
import ChatMessageComponent from "./ChatMessage.svelte"; import ChatMessageComponent from "./ChatMessage.svelte";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte"; import ExpandableTextarea from "./ExpandableTextarea.svelte";
import { Settings, Paperclip } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { X } from "@lucide/svelte";
const selectedModelStore = persistentStore<string>("playground-selected-model", ""); const selectedModelStore = persistentStore<string>("playground-selected-model", "");
const systemPromptStore = persistentStore<string>("playground-system-prompt", ""); const systemPromptStore = persistentStore<string>("playground-system-prompt", "");
@@ -30,6 +38,7 @@
let reasoningStartTime = $state<number>(0); let reasoningStartTime = $state<number>(0);
let abortController = $state<AbortController | null>(null); let abortController = $state<AbortController | null>(null);
let messagesContainer: HTMLDivElement | undefined = $state(); let messagesContainer: HTMLDivElement | undefined = $state();
let inputRef: HTMLTextAreaElement | null = $state(null);
let showSettings = $state(false); let showSettings = $state(false);
let attachedImages = $state<string[]>([]); let attachedImages = $state<string[]>([]);
let fileInput = $state<HTMLInputElement | null>(null); let fileInput = $state<HTMLInputElement | null>(null);
@@ -42,6 +51,14 @@
playgroundStores.chatStreaming.set(isStreaming); playgroundStores.chatStreaming.set(isStreaming);
}); });
let wasStreaming = $state(false);
$effect(() => {
if (wasStreaming && !isStreaming) {
inputRef?.focus();
}
wasStreaming = isStreaming;
});
function handleMessagesScroll() { function handleMessagesScroll() {
if (!messagesContainer) return; if (!messagesContainer) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer; const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
@@ -303,96 +320,95 @@
<div class="shrink-0 flex flex-wrap gap-2 mb-4"> <div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a model..." disabled={isStreaming} /> <ModelSelector bind:value={$selectedModelStore} placeholder="Select a model..." disabled={isStreaming} />
<div class="flex gap-2"> <div class="flex gap-2">
<button <Button variant="outline" size="icon" onclick={() => (showSettings = true)} title="Settings">
class="btn" <Settings />
onclick={() => (showSettings = !showSettings)} </Button>
title="Settings" <Button variant="outline" onclick={newChat} disabled={messages.length === 0 && !isStreaming}>
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M8.34 1.804A1 1 0 0 1 9.32 1h1.36a1 1 0 0 1 .98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 0 1 1.262.125l.962.962a1 1 0 0 1 .125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.295a1 1 0 0 1 .804.98v1.36a1 1 0 0 1-.804.98l-1.473.295a6.95 6.95 0 0 1-.587 1.416l.834 1.25a1 1 0 0 1-.125 1.262l-.962.962a1 1 0 0 1-1.262.125l-1.25-.834a6.953 6.953 0 0 1-1.416.587l-.295 1.473a1 1 0 0 1-.98.804H9.32a1 1 0 0 1-.98-.804l-.295-1.473a6.957 6.957 0 0 1-1.416-.587l-1.25.834a1 1 0 0 1-1.262-.125l-.962-.962a1 1 0 0 1-.125-1.262l.834-1.25a6.957 6.957 0 0 1-.587-1.416l-1.473-.295A1 1 0 0 1 1 10.68V9.32a1 1 0 0 1 .804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 0 1 .125-1.262l.962-.962A1 1 0 0 1 5.38 3.03l1.25.834a6.957 6.957 0 0 1 1.416-.587l.294-1.473ZM13 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd" />
</svg>
</button>
<button class="btn" onclick={newChat} disabled={messages.length === 0 && !isStreaming}>
New Chat New Chat
</button> </Button>
</div> </div>
</div> </div>
<!-- Settings panel --> <!-- Settings dialog -->
{#if showSettings} <Dialog.Root bind:open={showSettings}>
<div class="shrink-0 mb-4 p-4 bg-surface border border-gray-200 dark:border-white/10 rounded"> <Dialog.Content class="max-w-xl">
<div class="mb-4"> <Dialog.Header>
<label class="block text-sm font-medium mb-1" for="endpoint">Endpoint</label> <Dialog.Title>Chat Settings</Dialog.Title>
<select </Dialog.Header>
id="endpoint"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary" <div class="space-y-4">
bind:value={$endpointStore} <div>
disabled={isStreaming} <Label class="mb-1" for="endpoint">Endpoint</Label>
<Select.Root
type="single"
value={$endpointStore}
onValueChange={(v) => v && endpointStore.set(v as Endpoint)}
> >
<option value="v1/chat/completions">/v1/chat/completions</option> <Select.Trigger class="w-full">/{$endpointStore}</Select.Trigger>
<option value="v1/messages">/v1/messages</option> <Select.Content>
<option value="v1/responses">/v1/responses</option> <Select.Item value="v1/chat/completions">/v1/chat/completions</Select.Item>
</select> <Select.Item value="v1/messages">/v1/messages</Select.Item>
<Select.Item value="v1/responses">/v1/responses</Select.Item>
</Select.Content>
</Select.Root>
</div> </div>
<div class="mb-4"> <div>
<label class="block text-sm font-medium mb-1" for="system-prompt">System Prompt</label> <Label class="mb-1" for="system-prompt">System Prompt</Label>
<textarea <Textarea
id="system-prompt" id="system-prompt"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="resize-none"
placeholder="You are a helpful assistant..." placeholder="You are a helpful assistant..."
rows="3" rows={3}
bind:value={$systemPromptStore} bind:value={$systemPromptStore}
disabled={isStreaming} disabled={isStreaming}
></textarea> />
</div> </div>
<div class="mb-4"> <div>
<label class="block text-sm font-medium mb-1" for="temperature"> <Label class="mb-1" for="temperature">
Temperature: {$temperatureStore.toFixed(2)} Temperature: {$temperatureStore.toFixed(2)}
</label> </Label>
<input <input
id="temperature" id="temperature"
type="range" type="range"
min="0" min="0"
max="2" max="2"
step="0.05" step="0.05"
class="w-full" class="accent-primary w-full"
bind:value={$temperatureStore} bind:value={$temperatureStore}
disabled={isStreaming} disabled={isStreaming}
/> />
<div class="flex justify-between text-xs text-txtsecondary mt-1"> <div class="text-muted-foreground mt-1 flex justify-between text-xs">
<span>Precise (0)</span> <span>Precise (0)</span>
<span>Creative (2)</span> <span>Creative (2)</span>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1" for="max-tokens">Max Tokens</label> <Label class="mb-1" for="max-tokens">Max Tokens</Label>
<input <Input id="max-tokens" type="number" min="1" bind:value={$maxTokensStore} disabled={isStreaming} />
id="max-tokens" <p class="text-muted-foreground mt-1 text-xs">Required for /v1/messages.</p>
type="number"
min="1"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$maxTokensStore}
disabled={isStreaming}
/>
<p class="text-xs text-txtsecondary mt-1">Required for /v1/messages.</p>
</div> </div>
</div> </div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => (showSettings = false)}>Done</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="text-muted-foreground flex flex-1 items-center justify-center">
<p>No models configured. Add models to your configuration to start chatting.</p> <p>No models configured. Add models to your configuration to start chatting.</p>
</div> </div>
{:else} {:else}
<!-- Messages area --> <!-- Messages area -->
<div <div
class="flex-1 overflow-y-auto mb-4 px-2" class="mb-4 flex-1 overflow-y-auto px-2"
bind:this={messagesContainer} bind:this={messagesContainer}
onscroll={handleMessagesScroll} onscroll={handleMessagesScroll}
> >
{#if messages.length === 0} {#if messages.length === 0}
<div class="h-full flex items-center justify-center text-txtsecondary"> <div class="text-muted-foreground flex h-full items-center justify-center">
<p>Start a conversation by typing a message below.</p> <p>Start a conversation by typing a message below.</p>
</div> </div>
{:else} {:else}
@@ -419,19 +435,21 @@
{#if attachedImages.length > 0} {#if attachedImages.length > 0}
<div class="mb-2 flex flex-wrap gap-2"> <div class="mb-2 flex flex-wrap gap-2">
{#each attachedImages as imageUrl, idx (idx)} {#each attachedImages as imageUrl, idx (idx)}
<div class="relative group"> <div class="group relative">
<img <img
src={imageUrl} src={imageUrl}
alt="Attached image {idx + 1}" alt="Attached image {idx + 1}"
class="w-20 h-20 object-cover rounded border border-gray-200 dark:border-white/10" class="h-20 w-20 rounded-md border object-cover"
/> />
<button <Button
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" variant="destructive"
size="icon-sm"
class="absolute -right-2 -top-2 h-6 w-6 rounded-full opacity-0 transition-opacity group-hover:opacity-100"
onclick={() => removeImage(idx)} onclick={() => removeImage(idx)}
title="Remove image" title="Remove image"
> >
× <X class="size-3" />
</button> </Button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -439,7 +457,7 @@
<!-- Error message --> <!-- Error message -->
{#if imageError} {#if imageError}
<div class="mb-2 p-2 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded text-sm"> <div class="bg-destructive/10 text-destructive mb-2 rounded-md p-2 text-sm">
{imageError} {imageError}
</div> </div>
{/if} {/if}
@@ -456,6 +474,7 @@
/> />
<ExpandableTextarea <ExpandableTextarea
bind:ref={inputRef}
bind:value={userInput} bind:value={userInput}
placeholder="Type a message..." placeholder="Type a message..."
rows={3} rows={3}
@@ -464,27 +483,23 @@
/> />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if isStreaming} {#if isStreaming}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelStreaming}> <Button variant="destructive" onclick={cancelStreaming}>Cancel</Button>
Cancel
</button>
{:else} {:else}
<button <Button
class="btn" variant="outline"
size="icon"
onclick={() => fileInput?.click()} onclick={() => fileInput?.click()}
disabled={isStreaming || !$selectedModelStore} disabled={isStreaming || !$selectedModelStore}
title="Attach image" title="Attach image"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <Paperclip />
<path fill-rule="evenodd" d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" clip-rule="evenodd" /> </Button>
</svg> <Button
</button>
<button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={sendMessage} onclick={sendMessage}
disabled={(!userInput.trim() && attachedImages.length === 0) || !$selectedModelStore} disabled={(!userInput.trim() && attachedImages.length === 0) || !$selectedModelStore}
> >
Send Send
</button> </Button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { renderMarkdown, escapeHtml, renderStreamingMarkdown, createStreamingCache } from "../../lib/markdown"; import { renderMarkdown, escapeHtml, renderStreamingMarkdown, createStreamingCache } from "../../lib/markdown";
import type { RenderedBlock } from "../../lib/markdown"; import type { RenderedBlock } from "../../lib/markdown";
import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "lucide-svelte"; import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { getTextContent, getImageUrls } from "../../lib/types"; import { getTextContent, getImageUrls } from "../../lib/types";
import type { ContentPart } from "../../lib/types"; import type { ContentPart } from "../../lib/types";
@@ -161,37 +163,37 @@
<div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4"> <div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4">
<div <div
class="relative group rounded-lg px-4 py-2 {role === 'user' class="group relative rounded-lg px-4 py-2 {role === 'user'
? 'max-w-[85%] bg-primary text-btn-primary-text' ? 'bg-primary text-primary-foreground max-w-[85%]'
: 'w-full sm:w-4/5 bg-surface border border-gray-200 dark:border-white/10'}" : 'bg-card w-full border sm:w-4/5'}"
> >
{#if role === "assistant"} {#if role === "assistant"}
{#if reasoning_content || isReasoning} {#if reasoning_content || isReasoning}
<div class="mb-3 border border-gray-200 dark:border-white/10 rounded overflow-hidden"> <div class="mb-3 overflow-hidden rounded-md border">
<button <button
class="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-white/5 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors text-sm" class="bg-muted/50 hover:bg-muted flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors"
onclick={() => showReasoning = !showReasoning} onclick={() => showReasoning = !showReasoning}
> >
{#if showReasoning} {#if showReasoning}
<ChevronDown class="w-4 h-4" /> <ChevronDown class="size-4" />
{:else} {:else}
<ChevronRight class="w-4 h-4" /> <ChevronRight class="size-4" />
{/if} {/if}
<Brain class="w-4 h-4" /> <Brain class="size-4" />
<span class="font-medium">Reasoning</span> <span class="font-medium">Reasoning</span>
<span class="text-txtsecondary ml-2"> <span class="text-muted-foreground ml-2">
({reasoning_content.length} chars{#if !isReasoning && reasoningTimeMs > 0}, {formatDuration(reasoningTimeMs)}{/if}) ({reasoning_content.length} chars{#if !isReasoning && reasoningTimeMs > 0}, {formatDuration(reasoningTimeMs)}{/if})
</span> </span>
{#if isReasoning} {#if isReasoning}
<span class="ml-auto flex items-center gap-1 text-txtsecondary"> <span class="text-muted-foreground ml-auto flex items-center gap-1">
<span class="w-1.5 h-1.5 bg-primary rounded-full animate-pulse"></span> <span class="bg-primary h-1.5 w-1.5 animate-pulse rounded-full"></span>
reasoning... reasoning...
</span> </span>
{/if} {/if}
</button> </button>
{#if showReasoning} {#if showReasoning}
<div class="px-3 py-2 bg-gray-50/50 dark:bg-white/[0.02] text-sm text-txtsecondary whitespace-pre-wrap font-mono"> <div class="bg-muted/30 text-muted-foreground whitespace-pre-wrap px-3 py-2 font-mono text-sm">
{reasoning_content}{#if isReasoning}<span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5"></span>{/if} {reasoning_content}{#if isReasoning}<span class="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-current"></span>{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -201,12 +203,12 @@
{#each imageUrls as imageUrl, idx (idx)} {#each imageUrls as imageUrl, idx (idx)}
<button <button
onclick={() => openModal(imageUrl)} onclick={() => openModal(imageUrl)}
class="cursor-pointer rounded border border-gray-200 dark:border-white/10 hover:opacity-80 transition-opacity" class="cursor-pointer rounded-md border transition-opacity hover:opacity-80"
> >
<img <img
src={imageUrl} src={imageUrl}
alt="Image {idx + 1}" alt="Image {idx + 1}"
class="max-h-64 rounded" class="max-h-64 rounded-md"
/> />
</button> </button>
{/each} {/each}
@@ -226,60 +228,47 @@
</div> </div>
{/if} {/if}
{#if !isStreaming} {#if !isStreaming}
<div class="flex gap-1 mt-2 pt-1 border-t border-gray-200 dark:border-white/10"> <div class="mt-2 flex gap-1 border-t pt-1">
{#if onRegenerate} {#if onRegenerate}
<button <Button variant="ghost" size="icon-xs" class="text-muted-foreground" onclick={onRegenerate} title="Regenerate response">
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary" <RefreshCw />
onclick={onRegenerate} </Button>
title="Regenerate response"
>
<RefreshCw class="w-4 h-4" />
</button>
{/if} {/if}
<button <Button
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary" variant="ghost"
size="icon-xs"
class="text-muted-foreground"
onclick={copyToClipboard} onclick={copyToClipboard}
title={copied ? "Copied!" : "Copy to clipboard"} title={copied ? "Copied!" : "Copy to clipboard"}
> >
{#if copied} {#if copied}
<Check class="w-4 h-4 text-green-500" /> <Check class="text-success" />
{:else} {:else}
<Copy class="w-4 h-4" /> <Copy />
{/if} {/if}
</button> </Button>
<button <Button
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 {showRaw ? 'text-primary' : 'text-txtsecondary'}" variant="ghost"
size="icon-xs"
class={showRaw ? "text-primary" : "text-muted-foreground"}
onclick={() => showRaw = !showRaw} onclick={() => showRaw = !showRaw}
title={showRaw ? "Show rendered" : "Show raw"} title={showRaw ? "Show rendered" : "Show raw"}
> >
<Code class="w-4 h-4" /> <Code />
</button> </Button>
</div> </div>
{/if} {/if}
{:else} {:else}
{#if isEditing} {#if isEditing}
<div class="flex flex-col gap-2 min-w-[300px]"> <div class="flex min-w-[300px] flex-col gap-2">
<textarea <Textarea class="resize-none" rows={3} bind:value={editContent} onkeydown={handleKeyDown} />
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface text-txtmain focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows="3"
bind:value={editContent}
onkeydown={handleKeyDown}
></textarea>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <Button variant="ghost" size="icon-sm" onclick={cancelEdit} title="Cancel">
class="p-1.5 rounded hover:bg-white/20" <X />
onclick={cancelEdit} </Button>
title="Cancel" <Button variant="ghost" size="icon-sm" onclick={saveEdit} title="Save">
> <Save />
<X class="w-4 h-4" /> </Button>
</button>
<button
class="p-1.5 rounded hover:bg-white/20"
onclick={saveEdit}
title="Save"
>
<Save class="w-4 h-4" />
</button>
</div> </div>
</div> </div>
{:else} {:else}
@@ -288,12 +277,12 @@
{#each imageUrls as imageUrl, idx (idx)} {#each imageUrls as imageUrl, idx (idx)}
<button <button
onclick={() => openModal(imageUrl)} onclick={() => openModal(imageUrl)}
class="cursor-pointer rounded border border-white/20 hover:opacity-80 transition-opacity" class="cursor-pointer rounded-md border border-white/20 transition-opacity hover:opacity-80"
> >
<img <img
src={imageUrl} src={imageUrl}
alt="Image {idx + 1}" alt="Image {idx + 1}"
class="max-w-[200px] rounded" class="max-w-[200px] rounded-md"
/> />
</button> </button>
{/each} {/each}
@@ -302,11 +291,11 @@
<div class="whitespace-pre-wrap pr-8">{textContent}</div> <div class="whitespace-pre-wrap pr-8">{textContent}</div>
{#if canEdit} {#if canEdit}
<button <button
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity bg-white/20 hover:bg-white/30 shadow-sm" class="absolute right-2 top-2 rounded-lg bg-white/20 p-1.5 opacity-0 shadow-sm transition-opacity hover:bg-white/30 group-hover:opacity-100"
onclick={startEdit} onclick={startEdit}
title="Edit message" title="Edit message"
> >
<Pencil class="w-4 h-4" /> <Pencil class="size-4" />
</button> </button>
{/if} {/if}
{/if} {/if}
@@ -324,16 +313,16 @@
tabindex="-1" tabindex="-1"
> >
<button <button
class="absolute top-4 right-4 p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors" class="absolute right-4 top-4 rounded-lg bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
onclick={() => closeModal()} onclick={() => closeModal()}
title="Close" title="Close"
> >
<X class="w-6 h-6" /> <X class="size-6" />
</button> </button>
<img <img
src={modalImageUrl} src={modalImageUrl}
alt="" alt=""
class="max-w-full max-h-full rounded pointer-events-none" class="max-w-full max-h-full rounded-md pointer-events-none"
/> />
</div> </div>
{/if} {/if}
@@ -341,8 +330,8 @@
<style> <style>
.prose :global(pre) { .prose :global(pre) {
position: relative; position: relative;
background-color: var(--color-surface); background-color: var(--muted);
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2)); border: 1px solid var(--border);
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 0.75rem; padding: 0.75rem;
padding-right: 2.5rem; padding-right: 2.5rem;
@@ -359,20 +348,20 @@
justify-content: center; justify-content: center;
padding: 0.25rem; padding: 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
border: 1px solid var(--color-border); border: 1px solid var(--border);
background: var(--color-surface); background: var(--muted);
color: var(--color-txtsecondary); color: var(--muted-foreground);
cursor: pointer; cursor: pointer;
transition: background-color 0.15s; transition: background-color 0.15s;
line-height: 0; line-height: 0;
} }
.prose :global(.code-copy-btn:hover) { .prose :global(.code-copy-btn:hover) {
background: var(--color-secondary); background: var(--accent);
} }
.prose :global(.code-copy-btn.copied) { .prose :global(.code-copy-btn.copied) {
color: var(--color-success); color: var(--success);
opacity: 1; opacity: 1;
} }
@@ -387,10 +376,10 @@
} }
.prose :global(code:not(pre code)) { .prose :global(code:not(pre code)) {
background-color: var(--color-surface); background-color: var(--muted);
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2)); border: 1px solid var(--border);
} }
.prose :global(p) { .prose :global(p) {
@@ -431,14 +420,14 @@
} }
.prose :global(blockquote) { .prose :global(blockquote) {
border-left: 3px solid var(--color-primary); border-left: 3px solid var(--primary);
padding-left: 1rem; padding-left: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
font-style: italic; font-style: italic;
} }
.prose :global(a) { .prose :global(a) {
color: var(--color-primary); color: var(--primary);
text-decoration: underline; text-decoration: underline;
} }
@@ -450,13 +439,13 @@
.prose :global(th), .prose :global(th),
.prose :global(td) { .prose :global(td) {
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2)); border: 1px solid var(--border);
padding: 0.5rem; padding: 0.5rem;
text-align: left; text-align: left;
} }
.prose :global(th) { .prose :global(th) {
background-color: var(--color-surface); background-color: var(--muted);
font-weight: 600; font-weight: 600;
} }
@@ -2,6 +2,10 @@
import { models } from "../../stores/api"; import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent"; import { persistentStore } from "../../stores/persistent";
import { streamChatCompletion } from "../../lib/chatApi"; import { streamChatCompletion } from "../../lib/chatApi";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { X } from "@lucide/svelte";
type Status = "waiting" | "streaming" | "done" | "error"; type Status = "waiting" | "streaming" | "done" | "error";
type Phase = "waiting" | "loading" | "reasoning" | "content"; type Phase = "waiting" | "loading" | "reasoning" | "content";
@@ -366,38 +370,37 @@
<!-- Run controls --> <!-- Run controls -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if isRunning} {#if isRunning}
<button class="btn bg-red-500 hover:bg-red-600 text-white border-red-500" onclick={stop}> <Button variant="destructive" onclick={stop}>
<span class="inline-block w-3 h-3 bg-white align-middle mr-2"></span>Stop <span class="mr-1 inline-block h-3 w-3 bg-current align-middle"></span>Stop
</button> </Button>
{:else} {:else}
<button <Button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={run} onclick={run}
disabled={!canRun} disabled={!canRun}
title={$testListStore.length === 0 ? "Add models from the list below" : "Run concurrent requests"} title={$testListStore.length === 0 ? "Add models from the list below" : "Run concurrent requests"}
> >
<span class="inline-block align-middle mr-2" aria-hidden="true"></span>Go <span class="mr-1 inline-block align-middle" aria-hidden="true"></span>Go
</button> </Button>
{/if} {/if}
<button class="btn btn--sm" onclick={clearAll} disabled={isRunning || $testListStore.length === 0}> <Button variant="outline" size="sm" onclick={clearAll} disabled={isRunning || $testListStore.length === 0}>
Clear ({$testListStore.length}) Clear ({$testListStore.length})
</button> </Button>
</div> </div>
<!-- Available models --> <!-- Available models -->
<div class="flex flex-col min-h-0 flex-1"> <div class="flex flex-col min-h-0 flex-1">
<div class="text-xs font-medium text-txtsecondary mb-1"> <div class="text-xs font-medium text-muted-foreground mb-1">
Models <span class="text-[10px] font-normal">— click to queue (add the same model more than once to test parallel requests)</span> Models <span class="text-[10px] font-normal">— click to queue (add the same model more than once to test parallel requests)</span>
</div> </div>
<div class="flex-1 border border-gray-200 dark:border-white/10 rounded overflow-y-auto min-h-0"> <div class="flex-1 border border-border rounded-md overflow-y-auto min-h-0">
{#if !hasModels} {#if !hasModels}
<div class="p-3 text-sm text-txtsecondary text-center">No models configured.</div> <div class="p-3 text-sm text-muted-foreground text-center">No models configured.</div>
{:else} {:else}
<ul class="divide-y divide-gray-100 dark:divide-white/5"> <div class="divide-y divide-gray-100 dark:divide-white/5">
{#each availableModels as m (m.id)} {#each availableModels as m (m.id)}
<li>
<button <button
class="w-full text-left px-2 py-1.5 text-sm hover:bg-secondary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" type="button"
class="hover:bg-accent hover:text-foreground flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm font-normal transition-colors disabled:pointer-events-none disabled:opacity-50"
onclick={() => addModel(m.id)} onclick={() => addModel(m.id)}
disabled={isRunning} disabled={isRunning}
title="Add {m.id}" title="Add {m.id}"
@@ -405,38 +408,39 @@
<span class="text-primary" aria-hidden="true">+</span> <span class="text-primary" aria-hidden="true">+</span>
<span class="truncate flex-1">{m.id}</span> <span class="truncate flex-1">{m.id}</span>
</button> </button>
</li>
{/each} {/each}
</ul> </div>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Settings --> <!-- Settings -->
<div class="flex flex-col gap-2 border-t border-gray-200 dark:border-white/10 pt-3"> <div class="flex flex-col gap-2 border-t border-border pt-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label for="concurrency-prompt" class="text-xs font-medium text-txtsecondary">Prompt</label> <label for="concurrency-prompt" class="text-xs font-medium text-muted-foreground">Prompt</label>
<button <Button
class="text-[10px] text-txtsecondary hover:text-txtmain underline" variant="link"
size="sm"
class="h-auto p-0 text-[10px]"
onclick={resetDefaults} onclick={resetDefaults}
disabled={isRunning} disabled={isRunning}
> >
reset defaults reset defaults
</button> </Button>
</div> </div>
<textarea <Textarea
id="concurrency-prompt" id="concurrency-prompt"
class="w-full px-2 py-1.5 text-sm rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="resize-none text-sm"
rows="3" rows={3}
bind:value={$promptStore} bind:value={$promptStore}
disabled={isRunning} disabled={isRunning}
></textarea> ></Textarea>
<label for="concurrency-max-tokens" class="text-xs font-medium text-txtsecondary">max_tokens</label> <label for="concurrency-max-tokens" class="text-xs font-medium text-muted-foreground">max_tokens</label>
<input <Input
id="concurrency-max-tokens" id="concurrency-max-tokens"
type="number" type="number"
min="1" min="1"
class="w-full px-2 py-1.5 text-sm rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" class="h-8 text-sm"
bind:value={$maxTokensStore} bind:value={$maxTokensStore}
disabled={isRunning} disabled={isRunning}
/> />
@@ -447,8 +451,8 @@
<div class="flex-1 min-w-0 min-h-0 overflow-y-auto"> <div class="flex-1 min-w-0 min-h-0 overflow-y-auto">
{#if $testListStore.length === 0} {#if $testListStore.length === 0}
<div class="h-full flex items-center justify-center px-6"> <div class="h-full flex items-center justify-center px-6">
<div class="max-w-md text-sm text-txtsecondary space-y-4"> <div class="max-w-md text-sm text-muted-foreground space-y-4">
<h4 class="text-base font-semibold text-txtmain pb-0">Load Test</h4> <h4 class="text-base font-semibold text-foreground pb-0">Load Test</h4>
<p> <p>
Fire several streaming chat completions at llama-swap at the same time to see how it handles parallel Fire several streaming chat completions at llama-swap at the same time to see how it handles parallel
loading and concurrent inference. Each request streams into its own panel with a live timer and status. loading and concurrent inference. Each request streams into its own panel with a live timer and status.
@@ -456,16 +460,16 @@
<ol class="list-decimal list-inside space-y-1"> <ol class="list-decimal list-inside space-y-1">
<li>Click models on the left to queue them — repeat a model to hit it with parallel requests.</li> <li>Click models on the left to queue them — repeat a model to hit it with parallel requests.</li>
<li>Tweak the prompt and <code>max_tokens</code> if you want.</li> <li>Tweak the prompt and <code>max_tokens</code> if you want.</li>
<li>Press <span class="font-semibold text-txtmain">Go</span> to launch them concurrently.</li> <li>Press <span class="font-semibold text-foreground">Go</span> to launch them concurrently.</li>
</ol> </ol>
<p class="text-xs">Tip: drag a result card's header to reorder, or hit × to drop it.</p> <p class="text-xs">Tip: drag a result card's header to reorder, or hit × to drop it.</p>
</div> </div>
</div> </div>
{:else} {:else}
<!-- Gantt-style timeline --> <!-- Gantt-style timeline -->
<div class="mb-3 border border-gray-200 dark:border-white/10 rounded"> <div class="mb-3 border border-border rounded-md">
<button <button
class="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-txtsecondary hover:bg-secondary-hover transition-colors {$timelineCollapsedStore ? 'rounded' : 'rounded-t border-b border-gray-200 dark:border-white/10'}" class="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent transition-colors {$timelineCollapsedStore ? 'rounded-md' : 'rounded-t border-b border-border'}"
onclick={() => timelineCollapsedStore.update((v) => !v)} onclick={() => timelineCollapsedStore.update((v) => !v)}
aria-expanded={!$timelineCollapsedStore} aria-expanded={!$timelineCollapsedStore}
> >
@@ -480,7 +484,7 @@
</svg> </svg>
<span>Timeline</span> <span>Timeline</span>
{#if !$timelineCollapsedStore} {#if !$timelineCollapsedStore}
<span class="flex items-center gap-3 text-[10px] text-txtsecondary font-normal ml-3" aria-hidden="true"> <span class="flex items-center gap-3 text-[10px] text-muted-foreground font-normal ml-3" aria-hidden="true">
<span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-slate-200 dark:bg-white/10 border border-gray-300 dark:border-white/10"></span>waiting</span> <span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-slate-200 dark:bg-white/10 border border-gray-300 dark:border-white/10"></span>waiting</span>
<span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-slate-400 dark:bg-slate-500"></span>loading</span> <span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-slate-400 dark:bg-slate-500"></span>loading</span>
<span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-purple-500"></span>reasoning</span> <span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-purple-500"></span>reasoning</span>
@@ -489,7 +493,7 @@
<span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-red-500"></span>error</span> <span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-red-500"></span>error</span>
</span> </span>
{/if} {/if}
<span class="ml-auto tabular-nums text-txtsecondary"> <span class="ml-auto tabular-nums text-muted-foreground">
max {formatElapsed(timelineMaxMs)} · {$testListStore.length} request{$testListStore.length === 1 ? "" : "s"} max {formatElapsed(timelineMaxMs)} · {$testListStore.length} request{$testListStore.length === 1 ? "" : "s"}
</span> </span>
</button> </button>
@@ -498,13 +502,13 @@
<!-- X axis ticks --> <!-- X axis ticks -->
<div class="flex" aria-hidden="true"> <div class="flex" aria-hidden="true">
<div class="w-40 shrink-0"></div> <div class="w-40 shrink-0"></div>
<div class="relative flex-1 h-4 border-b border-gray-200 dark:border-white/10"> <div class="relative flex-1 h-4 border-b border-border">
{#each timelineTicks as t (t)} {#each timelineTicks as t (t)}
<div <div
class="absolute top-0 bottom-0 border-l border-gray-200 dark:border-white/10" class="absolute top-0 bottom-0 border-l border-border"
style="left: {(t / timelineMaxMs) * 100}%;" style="left: {(t / timelineMaxMs) * 100}%;"
> >
<span class="absolute -top-0.5 left-1 text-[10px] text-txtsecondary tabular-nums">{formatTickMs(t)}</span> <span class="absolute -top-0.5 left-1 text-[10px] text-muted-foreground tabular-nums">{formatTickMs(t)}</span>
</div> </div>
{/each} {/each}
</div> </div>
@@ -519,14 +523,14 @@
{@const reasoningPct = run ? (run.reasoningMs / timelineMaxMs) * 100 : 0} {@const reasoningPct = run ? (run.reasoningMs / timelineMaxMs) * 100 : 0}
{@const contentPct = run ? (run.contentMs / timelineMaxMs) * 100 : 0} {@const contentPct = run ? (run.contentMs / timelineMaxMs) * 100 : 0}
<div class="flex items-center text-xs"> <div class="flex items-center text-xs">
<div class="w-40 shrink-0 flex items-center gap-1 pr-2 text-txtsecondary"> <div class="w-40 shrink-0 flex items-center gap-1 pr-2 text-muted-foreground">
<span class="tabular-nums w-5 text-right">{i + 1}.</span> <span class="tabular-nums w-5 text-right">{i + 1}.</span>
<span class="truncate" title={entry.model}>{entry.model}</span> <span class="truncate" title={entry.model}>{entry.model}</span>
</div> </div>
<div class="relative flex-1 h-4"> <div class="relative flex-1 h-4">
{#each timelineTicks as t (t)} {#each timelineTicks as t (t)}
<div <div
class="absolute top-0 bottom-0 border-l border-gray-100 dark:border-white/5" class="absolute top-0 bottom-0 border-l border-border"
style="left: {(t / timelineMaxMs) * 100}%;" style="left: {(t / timelineMaxMs) * 100}%;"
aria-hidden="true" aria-hidden="true"
></div> ></div>
@@ -560,7 +564,7 @@
></div> ></div>
{/if} {/if}
</div> </div>
<div class="w-16 shrink-0 pl-2 tabular-nums text-txtsecondary text-right"> <div class="w-16 shrink-0 pl-2 tabular-nums text-muted-foreground text-right">
{run ? formatElapsed(run.elapsedMs) : "—"} {run ? formatElapsed(run.elapsedMs) : "—"}
</div> </div>
</div> </div>
@@ -574,16 +578,16 @@
{@const run = runs[entry.id]} {@const run = runs[entry.id]}
{@const status = run?.status ?? "waiting"} {@const status = run?.status ?? "waiting"}
<div <div
class="border rounded flex flex-col min-h-0 transition-colors {dragOverIndex === i && dragIndex !== i class="border rounded-md flex flex-col min-h-0 transition-colors {dragOverIndex === i && dragIndex !== i
? 'border-primary ring-2 ring-primary/40' ? 'border-primary ring-2 ring-primary/40'
: 'border-gray-200 dark:border-white/10'} {dragIndex === i ? 'opacity-40' : ''}" : 'border-border'} {dragIndex === i ? 'opacity-40' : ''}"
style="height: 280px;" style="height: 280px;"
role="listitem" role="listitem"
ondragover={(e) => onDragOver(i, e)} ondragover={(e) => onDragOver(i, e)}
ondrop={(e) => onDrop(i, e)} ondrop={(e) => onDrop(i, e)}
> >
<div <div
class="shrink-0 flex items-center gap-2 px-2 py-1.5 border-b border-gray-200 dark:border-white/10 bg-secondary/40 rounded-t" class="shrink-0 flex items-center gap-2 px-2 py-1.5 border-b border-border bg-secondary/40 rounded-t"
draggable={!isRunning} draggable={!isRunning}
role="button" role="button"
tabindex="-1" tabindex="-1"
@@ -593,26 +597,28 @@
class:cursor-grab={!isRunning} class:cursor-grab={!isRunning}
title={isRunning ? "" : "Drag to reorder"} title={isRunning ? "" : "Drag to reorder"}
> >
<span class="text-txtsecondary select-none" aria-hidden="true">⋮⋮</span> <span class="text-muted-foreground select-none" aria-hidden="true">⋮⋮</span>
<span class="text-txtsecondary tabular-nums text-xs w-5 text-right">{i + 1}.</span> <span class="text-muted-foreground tabular-nums text-xs w-5 text-right">{i + 1}.</span>
<span class="flex-1 truncate text-sm font-medium" title={entry.model}>{entry.model}</span> <span class="flex-1 truncate text-sm font-medium" title={entry.model}>{entry.model}</span>
<span class="text-xs tabular-nums text-txtsecondary"> <span class="text-xs tabular-nums text-muted-foreground">
{run ? formatElapsed(run.elapsedMs) : "—"} {run ? formatElapsed(run.elapsedMs) : "—"}
</span> </span>
<span class="status text-[10px] {statusBadgeClass(status)}">{status}</span> <span class="status text-[10px] {statusBadgeClass(status)}">{status}</span>
<button <Button
class="w-5 h-5 flex items-center justify-center text-txtsecondary hover:text-red-500 transition-colors rounded disabled:opacity-30 disabled:cursor-not-allowed" variant="ghost"
size="icon-sm"
class="h-5 w-5 text-muted-foreground hover:text-red-500"
onclick={() => removeEntry(entry.id)} onclick={() => removeEntry(entry.id)}
disabled={isRunning} disabled={isRunning}
aria-label="Remove" aria-label="Remove"
tabindex="-1" tabindex={-1}
> >
× <X class="size-3" />
</button> </Button>
</div> </div>
<div class="flex-1 min-h-0 overflow-y-auto font-mono text-xs px-2 py-1.5"> <div class="flex-1 min-h-0 overflow-y-auto font-mono text-xs px-2 py-1.5">
{#if run?.loadingText} {#if run?.loadingText}
<div class="bg-secondary/40 dark:bg-white/5 text-txtsecondary rounded px-2 py-1 mb-2 whitespace-pre-wrap">{run.loadingText.trim()}</div> <div class="bg-secondary/40 dark:bg-white/5 text-muted-foreground rounded-md px-2 py-1 mb-2 whitespace-pre-wrap">{run.loadingText.trim()}</div>
{/if} {/if}
{#if run?.reasoningContent} {#if run?.reasoningContent}
<div class="text-purple-700 dark:text-purple-300 whitespace-pre-wrap">{run.reasoningContent}</div> <div class="text-purple-700 dark:text-purple-300 whitespace-pre-wrap">{run.reasoningContent}</div>
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { Maximize2, X } from "lucide-svelte"; import { Maximize2, X } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
interface Props { interface Props {
value: string; value: string;
ref?: HTMLTextAreaElement | null;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
disabled?: boolean; disabled?: boolean;
@@ -12,6 +15,7 @@
let { let {
value = $bindable(), value = $bindable(),
ref = $bindable(null),
placeholder = "", placeholder = "",
rows = 3, rows = 3,
disabled = false, disabled = false,
@@ -52,69 +56,55 @@
}); });
</script> </script>
<div class="flex-1 relative group flex items-stretch min-h-0"> <div class="group relative flex min-h-0 flex-1 items-stretch">
<textarea <Textarea
class="w-full px-3 py-2 pr-10 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary resize-none" class="resize-none pr-10"
bind:ref
{placeholder} {placeholder}
{rows} {rows}
bind:value bind:value
{onkeydown} {onkeydown}
{disabled} {disabled}
></textarea> />
<button <Button
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-60 md:opacity-0 group-hover:opacity-100 transition-opacity bg-surface/90 hover:bg-surface border border-gray-200 dark:border-white/10 shadow-sm" variant="outline"
size="icon-sm"
class="absolute right-2 top-2 opacity-60 transition-opacity group-hover:opacity-100 md:opacity-0"
onclick={openExpanded} onclick={openExpanded}
title="Expand to edit" title="Expand to edit"
type="button" type="button"
{disabled} {disabled}
> >
<Maximize2 class="w-4 h-4" /> <Maximize2 />
</button> </Button>
</div> </div>
{#if isExpanded} {#if isExpanded}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-4xl h-[80vh] flex flex-col bg-surface rounded-lg shadow-xl border border-gray-200 dark:border-white/10"> <div class="bg-card flex h-[80vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-white/10"> <div class="flex items-center justify-between border-b p-4">
<h3 class="font-medium">Edit Text</h3> <h3 class="pb-0 font-medium">Edit Text</h3>
<button <Button variant="ghost" size="icon-sm" onclick={closeExpanded} title="Close" type="button">
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10" <X />
onclick={closeExpanded} </Button>
title="Close"
type="button"
>
<X class="w-5 h-5" />
</button>
</div> </div>
<!-- Textarea --> <!-- Textarea -->
<div class="flex-1 p-4"> <div class="flex-1 p-4">
<textarea <Textarea
bind:this={expandedTextarea} bind:ref={expandedTextarea}
class="w-full h-full px-4 py-3 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="h-full resize-none"
placeholder={placeholder} {placeholder}
bind:value={expandedValue} bind:value={expandedValue}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
></textarea> />
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-white/10"> <div class="flex justify-end gap-2 border-t p-4">
<button <Button variant="outline" onclick={closeExpanded} type="button">Cancel</Button>
class="btn" <Button onclick={saveExpanded} type="button">Done</Button>
onclick={closeExpanded}
type="button"
>
Cancel
</button>
<button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={saveExpanded}
type="button"
>
Done
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -7,6 +7,11 @@
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte"; import ExpandableTextarea from "./ExpandableTextarea.svelte";
import type { ImageApiMode, SdApiLora, SdApiLoraRef } from "../../lib/types"; import type { ImageApiMode, SdApiLora, SdApiLoraRef } from "../../lib/types";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { Download, X } from "@lucide/svelte";
const selectedModelStore = persistentStore<string>("playground-image-model", ""); const selectedModelStore = persistentStore<string>("playground-image-model", "");
const selectedSizeStore = persistentStore<string>("playground-image-size", "1024x1024"); const selectedSizeStore = persistentStore<string>("playground-image-size", "1024x1024");
@@ -61,18 +66,6 @@
} }
} }
function addLora(event: Event) {
const select = event.target as HTMLSelectElement;
const path = select.value;
if (!path) return;
const lora = availableLoras.find((l) => l.path === path);
if (lora && !selectedLoras.some((l) => l.path === path)) {
selectedLoras = [...selectedLoras, { path: lora.path, multiplier: 1.0 }];
}
select.value = "";
}
function removeLora(path: string) { function removeLora(path: string) {
selectedLoras = selectedLoras.filter((l) => l.path !== path); selectedLoras = selectedLoras.filter((l) => l.path !== path);
} }
@@ -195,65 +188,73 @@
<div class="shrink-0 flex flex-wrap gap-2 mb-4"> <div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} capabilities={["image_generation", "image_to_image"]} matchAny={true} /> <ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} capabilities={["image_generation", "image_to_image"]} matchAny={true} />
<select <Select.Root
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" type="single"
bind:value={$apiModeStore} value={$apiModeStore}
disabled={isGenerating} onValueChange={(v) => v && apiModeStore.set(v as ImageApiMode)}
> >
<option value="openai">OpenAI</option> <Select.Trigger class="h-9 w-32">{$apiModeStore}</Select.Trigger>
<option value="sdapi">SDAPI</option> <Select.Content>
</select> <Select.Item value="openai">OpenAI</Select.Item>
<Select.Item value="sdapi">SDAPI</Select.Item>
</Select.Content>
</Select.Root>
<select <Select.Root
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" type="single"
bind:value={$selectedSizeStore} value={$selectedSizeStore}
disabled={isGenerating} onValueChange={(v) => v && selectedSizeStore.set(v)}
> >
<optgroup label="Square"> <Select.Trigger class="h-9 w-40">{$selectedSizeStore}</Select.Trigger>
<option value="512x512">512x512</option> <Select.Content>
<option value="1024x1024">1024x1024</option> <Select.Group>
</optgroup> <Select.Label>Square</Select.Label>
<optgroup label="Landscape"> <Select.Item value="512x512">512x512</Select.Item>
<option value="1024x768">1024x768 (4:3)</option> <Select.Item value="1024x1024">1024x1024</Select.Item>
<option value="1280x720">1280x720 (16:9)</option> </Select.Group>
<option value="1792x1024">1792x1024 (SDXL)</option> <Select.Separator />
</optgroup> <Select.Group>
<optgroup label="Portrait"> <Select.Label>Landscape</Select.Label>
<option value="768x1024">768x1024 (3:4)</option> <Select.Item value="1024x768">1024x768 (4:3)</Select.Item>
<option value="720x1280">720x1280 (9:16)</option> <Select.Item value="1280x720">1280x720 (16:9)</Select.Item>
<option value="1024x1792">1024x1792 (SDXL)</option> <Select.Item value="1792x1024">1792x1024 (SDXL)</Select.Item>
</optgroup> </Select.Group>
</select> <Select.Separator />
<Select.Group>
<Select.Label>Portrait</Select.Label>
<Select.Item value="768x1024">768x1024 (3:4)</Select.Item>
<Select.Item value="720x1280">720x1280 (9:16)</Select.Item>
<Select.Item value="1024x1792">1024x1792 (SDXL)</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
{#if isSdapi} {#if isSdapi}
<button <Button variant="outline" onclick={() => showSettings = !showSettings}>
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface hover:bg-secondary-hover transition-colors"
onclick={() => showSettings = !showSettings}
>
{showSettings ? "Hide Settings" : "Settings"} {showSettings ? "Hide Settings" : "Settings"}
</button> </Button>
{/if} {/if}
</div> </div>
<!-- SDAPI Settings Panel --> <!-- SDAPI Settings Panel -->
{#if isSdapi && showSettings} {#if isSdapi && showSettings}
<div class="shrink-0 mb-4 p-4 rounded border border-gray-200 dark:border-white/10 bg-surface"> <div class="shrink-0 mb-4 p-4 rounded-md border border-border bg-background">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3"> <div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Steps</span> <span class="text-xs text-muted-foreground">Steps</span>
<input <Input
type="number" type="number"
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" class="h-8"
bind:value={$sdStepsStore} bind:value={$sdStepsStore}
min="1" min="1"
max="150" max="150"
/> />
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">CFG Scale</span> <span class="text-xs text-muted-foreground">CFG Scale</span>
<input <Input
type="number" type="number"
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" class="h-8"
bind:value={$sdCfgScaleStore} bind:value={$sdCfgScaleStore}
min="1" min="1"
max="30" max="30"
@@ -261,121 +262,141 @@
/> />
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Seed (-1 = random)</span> <span class="text-xs text-muted-foreground">Seed (-1 = random)</span>
<input <Input
type="number" type="number"
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" class="h-8"
bind:value={$sdSeedStore} bind:value={$sdSeedStore}
min="-1" min="-1"
/> />
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Batch Size</span> <span class="text-xs text-muted-foreground">Batch Size</span>
<input <Input
type="number" type="number"
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" class="h-8"
bind:value={$sdBatchSizeStore} bind:value={$sdBatchSizeStore}
min="1" min="1"
max="8" max="8"
/> />
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Sampler</span> <span class="text-xs text-muted-foreground">Sampler</span>
<select <Select.Root
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" type="single"
bind:value={$sdSamplerStore} value={$sdSamplerStore}
onValueChange={(v) => sdSamplerStore.set(v ?? "")}
> >
<option value="">Default</option> <Select.Trigger class="h-8">{$sdSamplerStore || "Default"}</Select.Trigger>
<option value="euler_a">euler_a</option> <Select.Content>
<option value="euler">euler</option> <Select.Item value="">Default</Select.Item>
<option value="heun">heun</option> <Select.Item value="euler_a">euler_a</Select.Item>
<option value="dpm2">dpm2</option> <Select.Item value="euler">euler</Select.Item>
<option value="dpmpp2s_a">dpmpp2s_a</option> <Select.Item value="heun">heun</Select.Item>
<option value="dpmpp2m">dpmpp2m</option> <Select.Item value="dpm2">dpm2</Select.Item>
<option value="dpmpp2mv2">dpmpp2mv2</option> <Select.Item value="dpmpp2s_a">dpmpp2s_a</Select.Item>
<option value="ipndm">ipndm</option> <Select.Item value="dpmpp2m">dpmpp2m</Select.Item>
<option value="ipndm_v">ipndm_v</option> <Select.Item value="dpmpp2mv2">dpmpp2mv2</Select.Item>
<option value="lcm">lcm</option> <Select.Item value="ipndm">ipndm</Select.Item>
<option value="ddim_trailing">ddim_trailing</option> <Select.Item value="ipndm_v">ipndm_v</Select.Item>
<option value="tcd">tcd</option> <Select.Item value="lcm">lcm</Select.Item>
</select> <Select.Item value="ddim_trailing">ddim_trailing</Select.Item>
<Select.Item value="tcd">tcd</Select.Item>
</Select.Content>
</Select.Root>
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Scheduler</span> <span class="text-xs text-muted-foreground">Scheduler</span>
<select <Select.Root
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" type="single"
bind:value={$sdSchedulerStore} value={$sdSchedulerStore}
onValueChange={(v) => sdSchedulerStore.set(v ?? "")}
> >
<option value="">Auto for model</option> <Select.Trigger class="h-8">{$sdSchedulerStore || "Auto for model"}</Select.Trigger>
<option value="discrete">discrete</option> <Select.Content>
<option value="karras">karras</option> <Select.Item value="">Auto for model</Select.Item>
<option value="exponential">exponential</option> <Select.Item value="discrete">discrete</Select.Item>
<option value="ays">ays</option> <Select.Item value="karras">karras</Select.Item>
<option value="gits">gits</option> <Select.Item value="exponential">exponential</Select.Item>
</select> <Select.Item value="ays">ays</Select.Item>
<Select.Item value="gits">gits</Select.Item>
</Select.Content>
</Select.Root>
</label> </label>
</div> </div>
<label class="flex flex-col gap-1 mb-3"> <label class="flex flex-col gap-1 mb-3">
<span class="text-xs text-txtsecondary">Negative Prompt</span> <span class="text-xs text-muted-foreground">Negative Prompt</span>
<textarea <Textarea
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary resize-y text-sm"
bind:value={$sdNegativePromptStore} bind:value={$sdNegativePromptStore}
rows="2" rows={2}
placeholder="Elements to avoid..." placeholder="Elements to avoid..."
></textarea> ></Textarea>
</label> </label>
<!-- LoRA Selection --> <!-- LoRA Selection -->
<div> <div>
<span class="text-xs text-txtsecondary block mb-1">LoRAs</span> <span class="text-xs text-muted-foreground block mb-1">LoRAs</span>
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<button <Button
class="px-3 py-1.5 text-sm rounded border border-gray-200 dark:border-white/10 bg-surface hover:bg-secondary-hover transition-colors disabled:opacity-50" variant="outline"
size="sm"
onclick={loadLoras} onclick={loadLoras}
disabled={!$selectedModelStore || isLoadingLoras} disabled={!$selectedModelStore || isLoadingLoras}
> >
{isLoadingLoras ? "Loading..." : lorasLoaded ? "Reload LoRAs" : "Load LoRAs"} {isLoadingLoras ? "Loading..." : lorasLoaded ? "Reload LoRAs" : "Load LoRAs"}
</button> </Button>
{#if lorasLoaded && availableLoras.length > 0} {#if lorasLoaded && availableLoras.length > 0}
<select <Select.Root
class="flex-1 px-2 py-1.5 text-sm rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" type="single"
onchange={addLora} value=""
onValueChange={(v) => {
if (v) {
const lora = availableLoras.find((l) => l.path === v);
if (lora && !selectedLoras.some((s) => s.path === v)) {
selectedLoras = [...selectedLoras, { path: lora.path, multiplier: 1.0 }];
}
}
}}
> >
<option value="">Add a LoRA...</option> <Select.Trigger class="h-8 flex-1">Add a LoRA...</Select.Trigger>
{#each availableLoras.filter((l) => !selectedLoras.some((s) => s.path === l.path)) as lora} <Select.Content>
<option value={lora.path}>{lora.name}</option> {#each availableLoras.filter((l) => !selectedLoras.some((s) => s.path === l.path)) as lora (lora.path)}
<Select.Item value={lora.path}>{lora.name}</Select.Item>
{/each} {/each}
</select> </Select.Content>
</Select.Root>
{/if} {/if}
</div> </div>
{#if loraError} {#if loraError}
<p class="text-xs text-red-500 mb-1">{loraError}</p> <p class="text-xs text-red-500 mb-1">{loraError}</p>
{/if} {/if}
{#if lorasLoaded && availableLoras.length === 0} {#if lorasLoaded && availableLoras.length === 0}
<p class="text-xs text-txtsecondary">No LoRAs available</p> <p class="text-xs text-muted-foreground">No LoRAs available</p>
{/if} {/if}
{#if selectedLoras.length > 0} {#if selectedLoras.length > 0}
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
{#each selectedLoras as lora} {#each selectedLoras as lora}
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<span class="flex-1 truncate">{getLoraName(lora.path)}</span> <span class="flex-1 truncate">{getLoraName(lora.path)}</span>
<input <Input
type="number" type="number"
class="w-20 px-1.5 py-1 text-xs rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-1 focus:ring-primary" class="h-7 w-20 text-xs"
value={lora.multiplier} value={lora.multiplier}
oninput={(e) => updateLoraMultiplier(lora.path, parseFloat((e.target as HTMLInputElement).value) || 1)} oninput={(e) => updateLoraMultiplier(lora.path, parseFloat((e.target as HTMLInputElement).value) || 1)}
min="0" min="0"
max="2" max="2"
step="0.1" step="0.1"
/> />
<button <Button
class="px-1.5 py-0.5 text-xs rounded border border-gray-200 dark:border-white/10 hover:bg-red-500 hover:text-white hover:border-red-500 transition-colors" variant="outline"
size="sm"
class="h-7 px-1.5 text-xs hover:bg-destructive hover:text-destructive-foreground"
onclick={() => removeLora(lora.path)} onclick={() => removeLora(lora.path)}
aria-label="Remove LoRA" aria-label="Remove LoRA"
> >
x <X class="size-3" />
</button> </Button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -386,14 +407,14 @@
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="flex-1 flex items-center justify-center text-muted-foreground">
<p>No models configured. Add models to your configuration to generate images.</p> <p>No models configured. Add models to your configuration to generate images.</p>
</div> </div>
{:else} {:else}
<!-- Image display area --> <!-- Image display area -->
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 rounded"> <div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-background border border-border rounded-md">
{#if isGenerating} {#if isGenerating}
<div class="text-center text-txtsecondary"> <div class="text-center text-muted-foreground">
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div> <div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
<p>Generating image...</p> <p>Generating image...</p>
</div> </div>
@@ -403,7 +424,6 @@
<p class="text-sm mt-1">{error}</p> <p class="text-sm mt-1">{error}</p>
</div> </div>
{:else if generatedImages.length > 1} {:else if generatedImages.length > 1}
<!-- Grid for multiple images (batch) -->
<div class="grid grid-cols-2 gap-2 p-2 w-full h-full overflow-auto"> <div class="grid grid-cols-2 gap-2 p-2 w-full h-full overflow-auto">
{#each generatedImages as img, i} {#each generatedImages as img, i}
<div class="relative flex items-center justify-center"> <div class="relative flex items-center justify-center">
@@ -418,15 +438,15 @@
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity" class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
/> />
</button> </button>
<button <Button
class="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors" variant="secondary"
size="icon"
class="absolute bottom-2 right-2 h-8 w-8 bg-black/60 hover:bg-black/80 text-white"
onclick={(e) => { e.stopPropagation(); downloadImage(i); }} onclick={(e) => { e.stopPropagation(); downloadImage(i); }}
aria-label="Download image" aria-label="Download image"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Download class="size-4" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path> </Button>
</svg>
</button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -443,18 +463,18 @@
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity" class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
/> />
</button> </button>
<button <Button
class="absolute bottom-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors" variant="secondary"
size="icon"
class="absolute bottom-2 right-2 bg-black/60 hover:bg-black/80 text-white"
onclick={(e) => { e.stopPropagation(); downloadImage(0); }} onclick={(e) => { e.stopPropagation(); downloadImage(0); }}
aria-label="Download image" aria-label="Download image"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Download class="size-5" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path> </Button>
</svg>
</button>
</div> </div>
{:else} {:else}
<div class="text-center text-txtsecondary"> <div class="text-center text-muted-foreground">
<p>Enter a prompt below to generate an image</p> <p>Enter a prompt below to generate an image</p>
</div> </div>
{/if} {/if}
@@ -471,24 +491,25 @@
/> />
<div class="flex flex-row md:flex-col gap-2"> <div class="flex flex-row md:flex-col gap-2">
{#if isGenerating} {#if isGenerating}
<button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}> <Button variant="destructive" class="flex-1 md:flex-none" onclick={cancelGeneration}>
Cancel Cancel
</button> </Button>
{:else} {:else}
<button <Button
class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none" class="flex-1 md:flex-none"
onclick={generate} onclick={generate}
disabled={!prompt.trim() || !$selectedModelStore} disabled={!prompt.trim() || !$selectedModelStore}
> >
Generate Generate
</button> </Button>
<button <Button
class="btn flex-1 md:flex-none" variant="outline"
class="flex-1 md:flex-none"
onclick={clearImage} onclick={clearImage}
disabled={generatedImages.length === 0 && !error && !prompt.trim()} disabled={generatedImages.length === 0 && !error && !prompt.trim()}
> >
Clear Clear
</button> </Button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -505,13 +526,15 @@
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
> >
<button <Button
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors" variant="secondary"
size="icon"
class="absolute top-4 right-4 bg-black/60 hover:bg-black/80 text-white"
onclick={() => closeFullscreen()} onclick={() => closeFullscreen()}
aria-label="Close fullscreen" aria-label="Close fullscreen"
> >
× <X class="size-6" />
</button> </Button>
<img <img
src={generatedImages[fullscreenIndex]} src={generatedImages[fullscreenIndex]}
alt="AI generated content" alt="AI generated content"
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { models } from "../../stores/api"; import { models } from "../../stores/api";
import { groupModels } from "../../lib/modelUtils"; import { groupModels } from "../../lib/modelUtils";
import * as Select from "$lib/components/ui/select/index.js";
interface Props { interface Props {
value: string; value: string;
@@ -18,42 +19,51 @@
</script> </script>
{#if hasModels} {#if hasModels}
<select <Select.Root
class="min-w-0 flex-1 basis-48 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" type="single"
bind:value {value}
onValueChange={(v) => v !== undefined && (value = v)}
{disabled} {disabled}
> >
<option value="">{placeholder}</option> <Select.Trigger class="min-w-0 flex-1 basis-48">{value || placeholder}</Select.Trigger>
<Select.Content>
<Select.Item value="">{placeholder}</Select.Item>
{#if hasMatching} {#if hasMatching}
<optgroup label="Matching Capabilities"> <Select.Group>
<Select.Label>Matching Capabilities</Select.Label>
{#each grouped.localMatching as model (model.id)} {#each grouped.localMatching as model (model.id)}
<option value={model.id}>{model.id}</option> <Select.Item value={model.id}>{model.id}</Select.Item>
{#if model.aliases} {#if model.aliases}
{#each model.aliases as alias (alias)} {#each model.aliases as alias (alias)}
<option value={alias}> {alias}</option> <Select.Item value={alias}> {alias}</Select.Item>
{/each} {/each}
{/if} {/if}
{/each} {/each}
</optgroup> </Select.Group>
<Select.Separator />
{/if} {/if}
{#if grouped.local.length > 0} {#if grouped.local.length > 0}
<optgroup label="Local"> <Select.Group>
<Select.Label>Local</Select.Label>
{#each grouped.local as model (model.id)} {#each grouped.local as model (model.id)}
<option value={model.id}>{model.id}</option> <Select.Item value={model.id}>{model.id}</Select.Item>
{#if model.aliases} {#if model.aliases}
{#each model.aliases as alias (alias)} {#each model.aliases as alias (alias)}
<option value={alias}> {alias}</option> <Select.Item value={alias}> {alias}</Select.Item>
{/each} {/each}
{/if} {/if}
{/each} {/each}
</optgroup> </Select.Group>
<Select.Separator />
{/if} {/if}
{#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)} {#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
<optgroup label="Peer: {peerId}"> <Select.Group>
<Select.Label>Peer: {peerId}</Select.Label>
{#each peerModels as model (model.id)} {#each peerModels as model (model.id)}
<option value={model.id}>{model.id}</option> <Select.Item value={model.id}>{model.id}</Select.Item>
{/each} {/each}
</optgroup> </Select.Group>
{/each} {/each}
</select> </Select.Content>
</Select.Root>
{/if} {/if}
@@ -7,7 +7,7 @@
</script> </script>
<div class="flex items-center justify-center h-full"> <div class="flex items-center justify-center h-full">
<div class="text-center text-txtsecondary"> <div class="text-muted-foreground text-center">
<p class="text-lg">{featureName}</p> <p class="text-lg">{featureName}</p>
<p class="text-sm mt-2">To be implemented</p> <p class="text-sm mt-2">To be implemented</p>
</div> </div>
@@ -4,6 +4,10 @@
import { rerank } from "../../lib/rerankApi"; import { rerank } from "../../lib/rerankApi";
import { playgroundStores } from "../../stores/playgroundActivity"; import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import * as ToggleGroup from "$lib/components/ui/toggle-group/index.js";
type RerankRow = { doc: string; score: number | null }; type RerankRow = { doc: string; score: number | null };
type SortOrder = "none" | "asc" | "desc"; type SortOrder = "none" | "asc" | "desc";
@@ -234,9 +238,9 @@
} }
function scoreColor(score: number | null): string { function scoreColor(score: number | null): string {
if (score === null) return "text-txtsecondary"; if (score === null) return "text-muted-foreground";
if (score > 0) return "text-green-600 dark:text-green-400"; if (score > 0) return "text-green-600 dark:text-green-400";
return "text-red-500 dark:text-red-400"; return "text-destructive";
} }
function formatScore(score: number | null): string { function formatScore(score: number | null): string {
@@ -266,9 +270,9 @@
<div class="shrink-0 flex flex-wrap gap-2 mb-4"> <div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a rerank model..." disabled={isLoading} capabilities={["reranker"]} /> <ModelSelector bind:value={$selectedModelStore} placeholder="Select a rerank model..." disabled={isLoading} capabilities={["reranker"]} />
{#if editorMode === "table"} {#if editorMode === "table"}
<input <Input
type="text" type="text"
class="min-w-0 flex-1 basis-48 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" class="min-w-0 flex-1 basis-48"
placeholder="Query..." placeholder="Query..."
bind:value={query} bind:value={query}
disabled={isLoading} disabled={isLoading}
@@ -276,60 +280,50 @@
/> />
{/if} {/if}
<!-- Table / JSON toggle --> <!-- Table / JSON toggle -->
<div class="flex rounded border border-gray-200 dark:border-white/10 overflow-hidden shrink-0"> <ToggleGroup.Root
<button type="single"
class="px-3 py-1.5 text-sm transition-colors {editorMode === 'table' variant="outline"
? 'bg-primary text-btn-primary-text' value={editorMode}
: 'bg-surface hover:bg-secondary-hover'}" onValueChange={(v) => v && (v === "table" ? switchToTable() : switchToJson())}
onclick={switchToTable} class="shrink-0"
disabled={isLoading}
> >
Table <ToggleGroup.Item value="table" disabled={isLoading}>Table</ToggleGroup.Item>
</button> <ToggleGroup.Item value="json" disabled={isLoading}>JSON</ToggleGroup.Item>
<button </ToggleGroup.Root>
class="px-3 py-1.5 text-sm border-l border-gray-200 dark:border-white/10 transition-colors {editorMode === 'json'
? 'bg-primary text-btn-primary-text'
: 'bg-surface hover:bg-secondary-hover'}"
onclick={switchToJson}
disabled={isLoading}
>
JSON
</button>
</div>
</div> </div>
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="text-muted-foreground flex flex-1 items-center justify-center">
<p>No models configured. Add models to your configuration to use reranking.</p> <p>No models configured. Add models to your configuration to use reranking.</p>
</div> </div>
{:else if editorMode === "json"} {:else if editorMode === "json"}
<!-- JSON editor --> <!-- JSON editor -->
<div class="flex-1 flex flex-col min-h-0 mb-4"> <div class="mb-4 flex min-h-0 flex-1 flex-col">
<textarea <Textarea
class="flex-1 w-full font-mono text-sm px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="w-full flex-1 resize-none font-mono text-sm"
bind:value={jsonText} bind:value={jsonText}
disabled={isLoading} disabled={isLoading}
placeholder={'{\n "query": "your search query",\n "documents": [\n "document one",\n "document two"\n ]\n}'} placeholder={'{\n "query": "your search query",\n "documents": [\n "document one",\n "document two"\n ]\n}'}
spellcheck={false} spellcheck={false}
></textarea> />
{#if jsonError} {#if jsonError}
<p class="mt-1 text-sm text-red-500">{jsonError}</p> <p class="text-destructive mt-1 text-sm">{jsonError}</p>
{/if} {/if}
</div> </div>
{:else} {:else}
<!-- Document table --> <!-- Document table -->
<div class="flex-1 overflow-y-auto mb-4 border border-gray-200 dark:border-white/10 rounded"> <div class="mb-4 flex-1 overflow-y-auto rounded-lg border">
<table class="w-full border-collapse table-fixed"> <table class="w-full table-fixed border-collapse">
<colgroup> <colgroup>
<col class="w-auto" /> <col class="w-auto" />
<col style="width: 120px" /> <col style="width: 120px" />
<col style="width: 40px" /> <col style="width: 40px" />
</colgroup> </colgroup>
<thead class="sticky top-0 bg-surface border-b border-gray-200 dark:border-white/10"> <thead class="bg-card sticky top-0 border-b">
<tr> <tr>
<th class="px-3 py-2 text-left text-sm font-medium text-txtsecondary">Document</th> <th class="text-muted-foreground px-3 py-2 text-left text-sm font-medium">Document</th>
<th <th
class="px-3 py-2 text-right text-sm font-medium text-txtsecondary cursor-pointer select-none hover:text-txtprimary transition-colors" class="text-muted-foreground hover:text-foreground cursor-pointer select-none px-3 py-2 text-right text-sm font-medium transition-colors"
onclick={cycleSortOrder} onclick={cycleSortOrder}
> >
Score{sortIndicator()} Score{sortIndicator()}
@@ -339,11 +333,11 @@
</thead> </thead>
<tbody> <tbody>
{#each displayRows as { row, i } (i)} {#each displayRows as { row, i } (i)}
<tr class="border-b border-gray-100 dark:border-white/5 last:border-0"> <tr class="border-b last:border-0">
<td class="px-3 py-1.5"> <td class="px-3 py-1.5">
<input <Input
type="text" type="text"
class="w-full bg-transparent focus:outline-none focus:ring-1 focus:ring-primary rounded px-1 py-0.5" class="border-0 focus-visible:ring-1 h-7 px-1 py-0.5 bg-transparent"
placeholder={i === rows.length - 1 ? "Add document..." : "Document text..."} placeholder={i === rows.length - 1 ? "Add document..." : "Document text..."}
value={row.doc} value={row.doc}
oninput={(e) => updateDoc(i, (e.target as HTMLInputElement).value)} oninput={(e) => updateDoc(i, (e.target as HTMLInputElement).value)}
@@ -353,21 +347,23 @@
</td> </td>
<td class="px-3 py-1.5 text-right font-mono text-sm {scoreColor(row.score)}"> <td class="px-3 py-1.5 text-right font-mono text-sm {scoreColor(row.score)}">
{#if isLoading && row.score === null && row.doc.trim() !== ""} {#if isLoading && row.score === null && row.doc.trim() !== ""}
<span class="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin align-middle"></span> <span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent align-middle"></span>
{:else} {:else}
{formatScore(row.score)} {formatScore(row.score)}
{/if} {/if}
</td> </td>
<td class="px-2 py-1.5 text-center"> <td class="px-2 py-1.5 text-center">
<button <Button
class="w-7 h-7 flex items-center justify-center text-txtsecondary hover:text-red-500 transition-colors rounded disabled:opacity-30 disabled:cursor-not-allowed" variant="ghost"
size="icon-sm"
class="h-7 w-7 text-muted-foreground hover:text-destructive"
onclick={() => deleteRow(i)} onclick={() => deleteRow(i)}
disabled={rows.length <= 1} disabled={rows.length <= 1}
tabindex="-1" tabindex={-1}
aria-label="Remove row" aria-label="Remove row"
> >
× ×
</button> </Button>
</td> </td>
</tr> </tr>
{/each} {/each}
@@ -378,28 +374,18 @@
<!-- Bottom toolbar --> <!-- Bottom toolbar -->
{#if hasModels} {#if hasModels}
<div class="shrink-0 flex flex-wrap items-center gap-2"> <div class="flex shrink-0 flex-wrap items-center gap-2">
{#if isLoading} {#if isLoading}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancel}> <Button variant="destructive" onclick={cancel}>Cancel</Button>
Cancel
</button>
{:else} {:else}
<button <Button onclick={submit} disabled={!canSubmit}>Rerank</Button>
class="btn bg-primary text-btn-primary-text hover:opacity-90" <Button variant="outline" onclick={clear} disabled={isCleared}>Clear</Button>
onclick={submit}
disabled={!canSubmit}
>
Rerank
</button>
<button class="btn" onclick={clear} disabled={isCleared}>
Clear
</button>
{/if} {/if}
{#if error} {#if error}
<span class="text-sm text-red-500 ml-2">{error}</span> <span class="text-destructive ml-2 text-sm">{error}</span>
{:else if usage} {:else if usage}
<span class="text-sm text-txtsecondary ml-2">{usage.total_tokens} tokens</span> <span class="text-muted-foreground ml-2 text-sm">{usage.total_tokens} tokens</span>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -5,6 +5,9 @@
import { playgroundStores } from "../../stores/playgroundActivity"; import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte"; import ExpandableTextarea from "./ExpandableTextarea.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { RefreshCw, Download } from "@lucide/svelte";
const selectedModelStore = persistentStore<string>("playground-speech-model", ""); const selectedModelStore = persistentStore<string>("playground-speech-model", "");
const selectedVoiceStore = persistentStore<string>("playground-speech-voice", "coral"); const selectedVoiceStore = persistentStore<string>("playground-speech-voice", "coral");
@@ -106,8 +109,7 @@
} }
} }
function handleVoiceChange(event: Event) { function handleVoiceChange(value: string) {
const value = (event.target as HTMLSelectElement).value;
if (value === "(refresh)") { if (value === "(refresh)") {
refreshVoices(); refreshVoices();
} else { } else {
@@ -208,49 +210,44 @@
<div class="shrink-0 flex gap-2 mb-4"> <div class="shrink-0 flex gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a speech model..." disabled={isGenerating} capabilities={["audio_speech"]} /> <ModelSelector bind:value={$selectedModelStore} placeholder="Select a speech model..." disabled={isGenerating} capabilities={["audio_speech"]} />
<div class="flex gap-2"> <div class="flex gap-2">
<select <Select.Root
class="shrink-0 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" type="single"
value={$selectedVoiceStore} value={$selectedVoiceStore}
onchange={handleVoiceChange} onValueChange={(v) => v && handleVoiceChange(v)}
disabled={isGenerating || isLoadingVoices || !$selectedModelStore}
> >
<Select.Trigger class="h-9 w-40">{$selectedVoiceStore}</Select.Trigger>
<Select.Content>
{#each availableVoices as voice (voice)} {#each availableVoices as voice (voice)}
<option value={voice}>{voice}</option> <Select.Item value={voice}>{voice}</Select.Item>
{/each} {/each}
<option value="(refresh)">(refresh)</option> <Select.Item value="(refresh)">(refresh)</Select.Item>
</select> </Select.Content>
</Select.Root>
{#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]} {#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]}
<button <Button
class="btn shrink-0" variant="outline"
size="icon"
class="shrink-0"
onclick={refreshVoices} onclick={refreshVoices}
disabled={isLoadingVoices} disabled={isLoadingVoices}
title={isLoadingVoices ? "Loading voices..." : "Load voices for this model"} title={isLoadingVoices ? "Loading voices..." : "Load voices for this model"}
> >
{#if isLoadingVoices} <RefreshCw class={isLoadingVoices ? "animate-spin" : ""} />
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"> </Button>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
{/if}
</button>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="flex-1 flex items-center justify-center text-muted-foreground">
<p>No models configured. Add models to your configuration to generate speech.</p> <p>No models configured. Add models to your configuration to generate speech.</p>
</div> </div>
{:else} {:else}
<!-- Audio display area --> <!-- Audio display area -->
<div class="shrink-0 mb-4 bg-surface border border-gray-200 dark:border-white/10 rounded p-4 md:p-6"> <div class="shrink-0 mb-4 bg-background border border-border rounded-md p-4 md:p-6">
{#if isGenerating} {#if isGenerating}
<div class="flex items-center justify-center text-txtsecondary py-8"> <div class="flex items-center justify-center text-muted-foreground py-8">
<div class="text-center"> <div class="text-center">
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div> <div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
<p>Generating speech...</p> <p>Generating speech...</p>
@@ -267,7 +264,7 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<!-- Header with metadata and download --> <!-- Header with metadata and download -->
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="flex flex-wrap gap-3 text-sm text-txtsecondary"> <div class="flex flex-wrap gap-3 text-sm text-muted-foreground">
{#if generatedVoice} {#if generatedVoice}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -285,15 +282,9 @@
</span> </span>
{/if} {/if}
</div> </div>
<button <Button variant="outline" size="icon" class="shrink-0" onclick={downloadAudio} title="Download audio file">
class="btn shrink-0" <Download />
onclick={downloadAudio} </Button>
title="Download audio file"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
</button>
</div> </div>
<!-- Audio player with larger controls --> <!-- Audio player with larger controls -->
@@ -305,7 +296,7 @@
</div> </div>
</div> </div>
{:else} {:else}
<div class="flex items-center justify-center text-txtsecondary py-8"> <div class="flex items-center justify-center text-muted-foreground py-8">
<div class="text-center"> <div class="text-center">
<svg class="w-12 h-12 md:w-16 md:h-16 mx-auto mb-2 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 md:w-16 md:h-16 mx-auto mb-2 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
@@ -327,24 +318,25 @@
/> />
<div class="shrink-0 flex md:flex-col gap-2"> <div class="shrink-0 flex md:flex-col gap-2">
{#if isGenerating} {#if isGenerating}
<button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}> <Button variant="destructive" class="flex-1 md:flex-none" onclick={cancelGeneration}>
Cancel Cancel
</button> </Button>
{:else} {:else}
<button <Button
class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none" class="flex-1 md:flex-none"
onclick={generate} onclick={generate}
disabled={!inputText.trim() || !$selectedModelStore} disabled={!inputText.trim() || !$selectedModelStore}
> >
Generate Generate
</button> </Button>
<button <Button
class="btn flex-1 md:flex-none" variant="outline"
class="flex-1 md:flex-none"
onclick={clearInput} onclick={clearInput}
disabled={!inputText.trim()} disabled={!inputText.trim()}
> >
Clear Clear
</button> </Button>
<label class="flex items-center justify-center gap-2 text-sm cursor-pointer"> <label class="flex items-center justify-center gap-2 text-sm cursor-pointer">
<input <input
type="checkbox" type="checkbox"
+151 -133
View File
@@ -1,178 +1,196 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "katex/dist/katex.min.css"; @import "katex/dist/katex.min.css";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme { @custom-variant dark (&:is(.dark *));
--color-background: rgba(252, 252, 249, 1);
--color-surface: rgba(255, 255, 253, 1);
/* text colors */ :root {
--color-txtmain: rgba(19, 52, 59, 1); --radius: 0;
--color-txtsecondary: rgba(98, 108, 113, 1);
--color-navlink-active: rgba(245, 245, 245, 1);
--color-primary: rgba(50, 184, 198, 1); /* shadcn base palette (zinc) */
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--color-primary-hover: rgba(29, 116, 128, 1); /* brand accent: llama-swap teal */
--color-primary-active: rgba(26, 104, 115, 1); --primary: rgb(50 184 198);
--color-secondary: rgba(94, 82, 64, 0.12); --primary-foreground: oklch(0.985 0 0);
--color-secondary-hover: rgba(94, 82, 64, 0.2);
--color-secondary-active: rgba(94, 82, 64, 0.25); --secondary: oklch(0.967 0.001 286.375);
--color-border: rgba(94, 82, 64, 0.3); --secondary-foreground: oklch(0.21 0.006 285.885);
--color-btn-primary-text: rgba(252, 252, 249, 1); --muted: oklch(0.967 0.001 286.375);
--color-card-border: rgba(94, 82, 64, 0.12); --muted-foreground: oklch(0.552 0.016 285.938);
--color-card-border-inner: rgba(94, 82, 64, 0.12); --accent: oklch(0.967 0.001 286.375);
--color-error: rgba(192, 21, 47, 1); --accent-foreground: oklch(0.21 0.006 285.885);
--color-success: rgba(33, 128, 141, 1); --destructive: oklch(0.577 0.245 27.325);
--color-warning: rgb(244, 155, 0); --border: oklch(0.92 0.004 286.32);
--color-info: rgba(98, 108, 113, 1); --input: oklch(0.92 0.004 286.32);
--color-focus-ring: rgba(33, 128, 141, 0.4); --ring: rgb(50 184 198);
--color-select-caret: rgba(19, 52, 59, 0.8);
--color-btn-border: rgba(94, 82, 64, 0.7); --chart-1: rgb(50 184 198);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: rgb(50 184 198);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: rgb(50 184 198);
/* semantic status colors (shared light/dark-aware below) */
--success: oklch(0.6 0.118 184.704);
--warning: oklch(0.769 0.17 70.08);
--info: oklch(0.552 0.016 285.938);
} }
@layer theme { .dark {
/* over ride theme for dark mode */ --background: oklch(0.141 0.005 285.823);
[data-theme="dark"] { --foreground: oklch(0.985 0 0);
--color-background: rgba(31, 33, 33, 1); --card: oklch(0.21 0.006 285.885);
--color-surface: rgba(38, 40, 40, 1); --card-foreground: oklch(0.985 0 0);
/* text colors */ --popover: oklch(0.21 0.006 285.885);
--color-txtmain: rgba(245, 245, 245, 1); --popover-foreground: oklch(0.985 0 0);
--color-txtsecondary: rgba(167, 169, 169, 0.7);
--color-navlink-active: rgba(245, 245, 245, 1); /* brand accent: deeper teal for dark surfaces */
--primary: rgb(45 166 178);
--primary-foreground: oklch(0.141 0.005 285.823);
--color-primary: rgba(33, 128, 141, 1); --secondary: oklch(0.274 0.006 286.033);
--color-primary-hover: rgba(45, 166, 178, 1); --secondary-foreground: oklch(0.985 0 0);
--color-primary-active: rgba(41, 150, 161, 1); --muted: oklch(0.274 0.006 286.033);
--color-secondary: rgba(119, 124, 124, 0.15); --muted-foreground: oklch(0.705 0.015 286.067);
--color-secondary-hover: rgba(119, 124, 124, 0.25); --accent: oklch(0.274 0.006 286.033);
--color-secondary-active: rgba(119, 124, 124, 0.3); --accent-foreground: oklch(0.985 0 0);
--color-border: rgba(119, 124, 124, 0.3); --destructive: oklch(0.704 0.191 22.216);
--color-error: rgba(255, 84, 89, 1); --border: oklch(1 0 0 / 10%);
--color-success: rgba(50, 184, 198, 1); --input: oklch(1 0 0 / 15%);
--color-warning: rgb(244, 155, 0); --ring: rgb(50 184 198);
--color-info: rgba(167, 169, 169, 1);
--color-focus-ring: rgba(50, 184, 198, 0.4); --chart-1: rgb(50 184 198);
--color-btn-primary-text: rgba(19, 52, 59, 1); --chart-2: oklch(0.696 0.17 162.48);
--color-card-border: rgba(119, 124, 124, 0.2); --chart-3: oklch(0.769 0.188 70.08);
--color-card-border-inner: rgba(119, 124, 124, 0.15); --chart-4: oklch(0.627 0.265 303.9);
--shadow-inset-sm: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 0 rgba(0, 0, 0, 0.15); --chart-5: oklch(0.645 0.246 16.439);
--button-border-secondary: rgba(119, 124, 124, 0.2);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: rgb(50 184 198);
--sidebar-primary-foreground: oklch(0.141 0.005 285.823);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: rgb(50 184 198);
--success: oklch(0.696 0.17 162.48);
--warning: oklch(0.769 0.17 70.08);
--info: oklch(0.705 0.015 286.067);
} }
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-error: var(--destructive);
} }
@layer base { @layer base {
* {
@apply border-border outline-ring/50;
}
body { body {
/* example of how colors using theme colors*/ @apply bg-background text-foreground;
@apply bg-background text-txtmain;
} }
h1 { h1 {
@apply text-4xl text-txtmain font-bold pb-4; @apply text-3xl font-bold tracking-tight pb-4;
} }
h2 { h2 {
@apply text-3xl text-txtmain font-bold pb-4; @apply text-2xl font-bold tracking-tight pb-4;
} }
h3 { h3 {
@apply text-2xl text-txtmain font-bold pb-4; @apply text-xl font-semibold tracking-tight pb-4;
} }
h4 { h4 {
@apply text-xl text-txtmain font-bold pb-4; @apply text-lg font-semibold pb-4;
} }
h5 { h5 {
@apply text-lg text-txtmain font-bold pb-4; @apply text-base font-semibold pb-4;
} }
h6 { h6 {
@apply text-base text-txtmain font-bold pb-4; @apply text-sm font-semibold pb-4;
} }
} }
/* define CSS classes here for specific types of components */
@layer components { @layer components {
.container { /* default padding for ad-hoc tables (header/detail views) */
@apply px-4;
}
/* Tables */
table th { table th {
@apply p-2 font-semibold; @apply p-2 font-semibold;
} }
table td { table td {
@apply p-2; @apply p-2;
} }
/* Navigation Header */
.navlink {
@apply text-txtsecondary hover:bg-secondary hover:text-txtmain rounded-lg p-2;
}
.navlink.active {
@apply bg-primary text-navlink-active;
} }
/* Card component */ @utility activity-link {
.card { background: linear-gradient(90deg, #6366f1, #8b5cf6, #a855f7, #8b5cf6, #6366f1);
@apply bg-surface rounded-lg border border-card-border shadow-sm overflow-hidden p-4; background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 2s linear infinite;
} }
.card:hover { @keyframes gradient-shift {
@apply shadow-md; 0% {
background-position: 0% 50%;
} }
100% {
.card__body { background-position: 200% 50%;
@apply p-4;
}
.card__header,
.card__footer {
@apply p-4 border-b border-card-border-inner;
}
/* Status Badges */
.status {
@apply inline-block px-2 py-1 text-xs font-medium rounded-lg;
}
.status--ready {
@apply bg-success/10 text-success;
}
.status--starting,
.status--stopping,
.status--queued {
@apply bg-warning/10 text-warning;
}
.status--stopped {
@apply bg-error/10 text-error;
}
/* Buttons */
.btn {
@apply bg-surface py-2 px-4 text-sm rounded-md border transition-colors duration-200 border-btn-border;
}
.btn:hover {
cursor: pointer;
}
.btn--sm {
@apply px-2 py-0.5 text-xs;
}
.btn:disabled {
@apply opacity-50 cursor-not-allowed;
}
}
@layer utilities {
.ml-2 {
margin-left: 0.5rem;
}
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
} }
} }
@@ -0,0 +1,49 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "h-5 gap-1 rounded-none border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn(
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
</script>
<div
bind:this={ref}
data-slot="card"
data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
@@ -0,0 +1,19 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content
bind:ref
data-slot="collapsible-content"
class={className}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Content>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger
bind:ref
data-slot="collapsible-trigger"
class={className}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Trigger>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root
bind:ref
data-slot="collapsible"
class={className}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Root>
@@ -0,0 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Trigger,
Content,
//
Root as Collapsible,
Trigger as CollapsibleTrigger,
Content as CollapsibleContent,
};
@@ -0,0 +1,142 @@
import {
type RowData,
type TableOptions,
type TableOptionsResolved,
type TableState,
type Updater,
createTable,
} from "@tanstack/table-core";
/**
* Creates a reactive TanStack table object for Svelte.
* @param options Table options to create the table with.
* @returns A reactive table object.
* @example
* ```svelte
* <script>
* const table = createSvelteTable({ ... })
* </script>
*
* <table>
* <thead>
* {#each table.getHeaderGroups() as headerGroup}
* <tr>
* {#each headerGroup.headers as header}
* <th colspan={header.colSpan}>
* <FlexRender content={header.column.columnDef.header} context={header.getContext()} />
* </th>
* {/each}
* </tr>
* {/each}
* </thead>
* <!-- ... -->
* </table>
* ```
*/
export function createSvelteTable<TData extends RowData>(options: TableOptions<TData>) {
const resolvedOptions: TableOptionsResolved<TData> = mergeObjects(
{
state: {},
onStateChange() {},
renderFallbackValue: null,
mergeOptions: (
defaultOptions: TableOptions<TData>,
options: Partial<TableOptions<TData>>
) => {
return mergeObjects(defaultOptions, options);
},
},
options
);
const table = createTable(resolvedOptions);
let state = $state<TableState>(table.initialState);
function updateOptions() {
table.setOptions(() => {
return mergeObjects(resolvedOptions, options, {
state: mergeObjects(state, options.state || {}),
onStateChange: (updater: Updater<TableState>) => {
if (updater instanceof Function) state = updater(state);
else state = mergeObjects(state, updater);
options.onStateChange?.(updater);
},
});
});
}
updateOptions();
$effect.pre(() => {
updateOptions();
});
return table;
}
type MaybeThunk<T extends object> = T | (() => T | null | undefined);
type Intersection<T extends readonly unknown[]> = (T extends [infer H, ...infer R]
? H & Intersection<R>
: unknown) & {};
/**
* Lazily merges several objects (or thunks) while preserving
* getter semantics from every source.
*
* Proxy-based to avoid known WebKit recursion issue.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mergeObjects<Sources extends readonly MaybeThunk<any>[]>(
...sources: Sources
): Intersection<{ [K in keyof Sources]: Sources[K] }> {
const resolve = <T extends object>(src: MaybeThunk<T>): T | undefined =>
typeof src === "function" ? (src() ?? undefined) : src;
const findSourceWithKey = (key: PropertyKey) => {
for (let i = sources.length - 1; i >= 0; i--) {
const obj = resolve(sources[i]);
if (obj && key in obj) return obj;
}
return undefined;
};
return new Proxy(Object.create(null), {
get(_, key) {
const src = findSourceWithKey(key);
return src?.[key as never];
},
has(_, key) {
return !!findSourceWithKey(key);
},
ownKeys(): (string | symbol)[] {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const all = new Set<string | symbol>();
for (const s of sources) {
const obj = resolve(s);
if (obj) {
for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) {
all.add(k);
}
}
}
return [...all];
},
getOwnPropertyDescriptor(_, key) {
const src = findSourceWithKey(key);
if (!src) return undefined;
return {
configurable: true,
enumerable: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: (src as any)[key],
writable: true,
};
},
}) as Intersection<{ [K in keyof Sources]: Sources[K] }>;
}
@@ -0,0 +1,40 @@
<script
lang="ts"
generics="TData, TValue, TContext extends HeaderContext<TData, TValue> | CellContext<TData, TValue>"
>
import type { CellContext, ColumnDefTemplate, HeaderContext } from "@tanstack/table-core";
import { RenderComponentConfig, RenderSnippetConfig } from "./render-helpers.js";
import type { Attachment } from "svelte/attachments";
type Props = {
/** The cell or header field of the current cell's column definition. */
content?: TContext extends HeaderContext<TData, TValue>
? ColumnDefTemplate<HeaderContext<TData, TValue>>
: TContext extends CellContext<TData, TValue>
? ColumnDefTemplate<CellContext<TData, TValue>>
: never;
/** The result of the `getContext()` function of the header or cell */
context: TContext;
/** Used to pass attachments that can't be gotten through context */
attach?: Attachment;
};
let { content, context, attach }: Props = $props();
</script>
{#if typeof content === "string"}
{content}
{:else if content instanceof Function}
<!-- It's unlikely that a CellContext will be passed to a Header -->
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
{@const result = content(context as any)}
{#if result instanceof RenderComponentConfig}
{@const { component: Component, props } = result}
<Component {...props} {attach} />
{:else if result instanceof RenderSnippetConfig}
{@const { snippet, params } = result}
{@render snippet({ ...params, attach })}
{:else}
{result}
{/if}
{/if}
@@ -0,0 +1,3 @@
export { default as FlexRender } from "./flex-render.svelte";
export { renderComponent, renderSnippet } from "./render-helpers.js";
export { createSvelteTable } from "./data-table.svelte.js";
@@ -0,0 +1,111 @@
import type { Component, ComponentProps, Snippet } from "svelte";
/**
* A helper class to make it easy to identify Svelte components in
* `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderComponentConfig}
* {@const { component: Component, props } = result}
* <Component {...props} />
* {/if}
* ```
*/
export class RenderComponentConfig<TComponent extends Component> {
component: TComponent;
props: ComponentProps<TComponent> | Record<string, never>;
constructor(
component: TComponent,
props: ComponentProps<TComponent> | Record<string, never> = {}
) {
this.component = component;
this.props = props;
}
}
/**
* A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderSnippetConfig}
* {@const { snippet, params } = result}
* {@render snippet(params)}
* {/if}
* ```
*/
export class RenderSnippetConfig<TProps> {
snippet: Snippet<[TProps]>;
params: TProps;
constructor(snippet: Snippet<[TProps]>, params: TProps) {
this.snippet = snippet;
this.params = params;
}
}
/**
* A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties.
*
* This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets.
*
* @param component A Svelte component
* @param props The props to pass to `component`
* @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* header: header => renderComponent(SortHeader, { label: 'Name', header }),
* }),
* columnHelper.accessor('state', {
* header: header => renderComponent(SortHeader, { label: 'State', header }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderComponent<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends Component<any>,
Props extends ComponentProps<T>,
>(component: T, props: Props = {} as Props) {
return new RenderComponentConfig(component, props);
}
/**
* A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties.
*
* The snippet must only take one parameter.
*
* This is only to be used with Snippets - use `renderComponent` for Svelte Components.
*
* @param snippet
* @param params
* @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }),
* }),
* columnHelper.accessor('state', {
* cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderSnippet<TProps>(snippet: Snippet<[TProps]>, params: TProps = {} as TProps) {
return new RenderSnippetConfig(snippet, params);
}
@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />
@@ -0,0 +1,48 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
import XIcon from '@lucide/svelte/icons/x';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close data-slot="dialog-close">
{#snippet child({ props })}
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm" {...props}>
<XIcon />
<span class="sr-only">Close</span>
</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
children,
showCloseButton = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Close</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("gap-2 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-base leading-none font-medium", className)}
{...restProps}
/>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>
@@ -0,0 +1,44 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import MinusIcon from '@lucide/svelte/icons/minus';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<MinusIcon />
{:else if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,31 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
sideOffset = 4,
align = "start",
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
class={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-(--bits-dropdown-menu-anchor-width) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7 data-[inset]:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,34 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 w-auto", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,54 @@
import Root from "./dropdown-menu.svelte";
import Sub from "./dropdown-menu-sub.svelte";
import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte";
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
import Content from "./dropdown-menu-content.svelte";
import Group from "./dropdown-menu-group.svelte";
import Item from "./dropdown-menu-item.svelte";
import Label from "./dropdown-menu-label.svelte";
import RadioGroup from "./dropdown-menu-radio-group.svelte";
import RadioItem from "./dropdown-menu-radio-item.svelte";
import Separator from "./dropdown-menu-separator.svelte";
import Shortcut from "./dropdown-menu-shortcut.svelte";
import Trigger from "./dropdown-menu-trigger.svelte";
import SubContent from "./dropdown-menu-sub-content.svelte";
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
import GroupHeading from "./dropdown-menu-group-heading.svelte";
import Portal from "./dropdown-menu-portal.svelte";
export {
CheckboxGroup,
CheckboxItem,
Content,
Portal,
Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Portal as DropdownMenuPortal,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
@@ -0,0 +1,48 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
className
)}
{...restProps}
/>
@@ -0,0 +1,13 @@
import { Pane } from "paneforge";
import Handle from "./resizable-handle.svelte";
import PaneGroup from "./resizable-pane-group.svelte";
export {
PaneGroup,
Pane,
Handle,
//
PaneGroup as ResizablePaneGroup,
Pane as ResizablePane,
Handle as ResizableHandle,
};
@@ -0,0 +1,27 @@
<script lang="ts">
import * as ResizablePrimitive from "paneforge";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
withHandle = false,
...restProps
}: WithoutChildrenOrChild<ResizablePrimitive.PaneResizerProps> & {
withHandle?: boolean;
} = $props();
</script>
<ResizablePrimitive.PaneResizer
bind:ref
data-slot="resizable-handle"
class={cn(
"cn-resizable-handle bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:w-full data-[direction=vertical]:after:translate-x-0 data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90",
className
)}
{...restProps}
>
{#if withHandle}
<div class="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0"></div>
{/if}
</ResizablePrimitive.PaneResizer>
@@ -0,0 +1,24 @@
<script lang="ts">
import * as ResizablePrimitive from "paneforge";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
this: paneGroup = $bindable(),
class: className,
...restProps
}: ResizablePrimitive.PaneGroupProps & {
this?: ResizablePrimitive.PaneGroup;
} = $props();
</script>
<ResizablePrimitive.PaneGroup
bind:ref
bind:this={paneGroup}
data-slot="resizable-pane-group"
class={cn(
"cn-resizable-panel-group flex h-full w-full data-[direction=vertical]:flex-col",
className
)}
{...restProps}
/>
@@ -0,0 +1,10 @@
import Scrollbar from "./scroll-area-scrollbar.svelte";
import Root from "./scroll-area.svelte";
export {
Root,
Scrollbar,
//,
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar,
};
@@ -0,0 +1,30 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
{orientation}
class={cn(
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
className
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
class="rounded-full bg-border relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
@@ -0,0 +1,43 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { Scrollbar } from "./index.js";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
viewportRef = $bindable(null),
class: className,
orientation = "vertical",
scrollbarXClasses = "",
scrollbarYClasses = "",
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: "vertical" | "horizontal" | "both" | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
viewportRef?: HTMLElement | null;
} = $props();
</script>
<ScrollAreaPrimitive.Root
bind:ref
data-slot="scroll-area"
class={cn("relative", className)}
{...restProps}
>
<ScrollAreaPrimitive.Viewport
bind:ref={viewportRef}
data-slot="scroll-area-viewport"
class="cn-scroll-area-viewport focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === "vertical" || orientation === "both"}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === "horizontal" || orientation === "both"}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
@@ -0,0 +1,37 @@
import Root from "./select.svelte";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};
@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 overflow-x-hidden overflow-y-auto",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group
bind:ref
data-slot="select-group"
class={cn("scroll-my-1 p-1", className)}
{...restProps}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 focus:bg-accent data-highlighted:bg-accent data-highlighted:text-accent-foreground focus:text-accent-foreground relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="cn-select-item-indicator-icon" />
{/if}
</span>
<span class="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
</span>
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-1.5 py-1 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", className)}
{...restProps}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>

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