fix(pi): add snap node compatibility diagnostics and documentation
- Added detailed error logging to detect snap node compatibility issues - When Pi process exits with code 1, log helpful diagnostic info including node path - Add hint to check for snap node and reinstall via apt/nvm - Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide - Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes - Provide clear installation instructions for NodeSource and nvm alternatives
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
# Automation
|
||||
|
||||
## Overview
|
||||
|
||||
The `/automation` route is the hub for managing AI agent capabilities: skills, tools, tasks, and processes. Each capability is a markdown file (with YAML frontmatter) stored on the filesystem. There is no relational database for automation data.
|
||||
|
||||
The page uses a resizable `WorkspaceView` layout with three panels: a sidebar to pick categories, a right panel for browsing/viewing, and an optional bottom-right chat panel for AI-assisted editing.
|
||||
|
||||
```
|
||||
+------------------+------------------------------------------+
|
||||
| Sidebar (20%) | Right Panel (80%) |
|
||||
| | - CapabilityList (browse) |
|
||||
| 8 categories | - CapabilityDetailView (selected item) |
|
||||
| | - New* form (creating) |
|
||||
| +------------------------------------------+
|
||||
| | AutomationEditChat (when editing, 50%) |
|
||||
+------------------+------------------------------------------+
|
||||
```
|
||||
|
||||
## Route & Entry Point
|
||||
|
||||
Defined in `App.tsx`:
|
||||
```tsx
|
||||
<Route path="/automation" element={<Dashboard.Automation />} />
|
||||
<Route path="/new-automation" element={<Dashboard.NewAutomation />} /> // stub, not implemented
|
||||
```
|
||||
|
||||
Entry component: `index.tsx` (`Automation`). Manages the `WorkspaceView` layout and dynamically adds/removes the chat panel based on `selection.editing`.
|
||||
|
||||
## Capability Categories
|
||||
|
||||
| Category | Kind | Backend Route | Status |
|
||||
|----------|------|---------------|--------|
|
||||
| Skills | `Skill` | `GET/POST/DELETE /api/skills` | Working |
|
||||
| Tools | `Tool` | `GET/POST/DELETE /api/tools` | Working |
|
||||
| Tasks | `Task` | `GET/POST/DELETE /api/tasks` | Working |
|
||||
| Processes | `Process` | `GET/POST/DELETE /api/processes` | Working |
|
||||
| Pipelines | `Pipeline` | None | Placeholder (404) |
|
||||
| Workflows | `Workflow` | None | Placeholder (404) |
|
||||
| Crons | `Cron` | None | Placeholder (404) |
|
||||
| Services | `Service` | None | Placeholder (404) |
|
||||
|
||||
Defined as `capabilityItems` in `AutomationSidebar.tsx`.
|
||||
|
||||
## State Management
|
||||
|
||||
All cross-panel communication uses a single shared channel:
|
||||
|
||||
```ts
|
||||
usePanelChannel<AutomationSelection>('automation:selected-capability', null)
|
||||
```
|
||||
|
||||
`AutomationSelection` (defined in `AutomationRightPanel.tsx`):
|
||||
```ts
|
||||
type AutomationSelection = {
|
||||
kind: string; // 'Skill' | 'Task' | 'Tool' | 'Process' | etc.
|
||||
endpoint: string; // '/skills' | '/tasks' | etc.
|
||||
queryKey: string; // React Query cache key
|
||||
dirName: string; // '' = list view, non-empty = detail view
|
||||
isNew?: boolean; // just created, AI chat opens in creation mode
|
||||
editing?: boolean; // chat panel is open
|
||||
creating?: boolean; // New* form is shown
|
||||
description?: string; // passed to AI as initial context
|
||||
} | null;
|
||||
```
|
||||
|
||||
Layout state persisted via `useDashboardState('screens/automation')`.
|
||||
|
||||
## Components
|
||||
|
||||
### `AutomationSidebar.tsx`
|
||||
Left nav with the 8 category buttons. Clicking sets selection to `{ kind, endpoint, queryKey, dirName: '' }` (list view).
|
||||
|
||||
### `AutomationRightPanel.tsx`
|
||||
Routes to one of three views based on selection state:
|
||||
- `creating === true` -> `New*` form (mapped via `newComponentMap[kind]`)
|
||||
- `dirName === ''` -> `CapabilityList` (browse items)
|
||||
- `dirName !== ''` -> `CapabilityDetailView` (detail for specific item)
|
||||
|
||||
### `CapabilityList.tsx`
|
||||
Fetches items from `GET {endpoint}`, renders filterable list. Has `+` button to set `creating: true`.
|
||||
|
||||
### `CapabilityDetailView.tsx`
|
||||
Shows a selected capability's detail (frontmatter + markdown body). Header actions:
|
||||
- **Back arrow** - returns to list view
|
||||
- **Run** (tasks only) - parses `inputs:` from frontmatter YAML, opens `TaskRunnerModal`
|
||||
- **Edit** - toggles `selection.editing` to open/close chat panel
|
||||
- **Delete** - confirmation dialog, then `DELETE {endpoint}/{dirName}`
|
||||
|
||||
Task input parsing (`parseInputs`) supports: `string`, `number`, `boolean`, `select` types.
|
||||
|
||||
### `AutomationEditChat.tsx`
|
||||
Bottom chat panel for AI-assisted editing. Uses `CapabilityChat` from `CapabilityPage.tsx`. Features:
|
||||
- Delete chat history button (`DELETE {endpoint}/{dirName}/chat`)
|
||||
- Close button (sets `editing: false`)
|
||||
- On AI response end, invalidates both individual and list query caches
|
||||
|
||||
### `New*.tsx` (8 components)
|
||||
`NewTask`, `NewSkill`, `NewTool`, `NewProcess`, `NewPipeline`, `NewCron`, `NewService`, `NewWorkflow`. All structurally identical:
|
||||
1. Name + description form
|
||||
2. `POST {endpoint}` with `{ name }`
|
||||
3. On success: transitions to detail view with `isNew: true, editing: true` (opens AI chat in creation mode)
|
||||
|
||||
## Shared Components (`CapabilityPage.tsx`)
|
||||
|
||||
Located at `../CapabilityPage.tsx`. Exports used by automation:
|
||||
|
||||
- **`CapabilityChat`** - AI chat wired to Pi agent via `usePiChat()` + `EmbeddableChat`. Injects `promptFrontmatter` prefix before every message. For `isNew === true`, uses rich creation guides (`buildTaskCreationPrefix`, `buildSkillCreationPrefix`, `buildToolCreationPrefix`).
|
||||
- **`FrontmatterBlock`** - Collapsible YAML frontmatter display.
|
||||
- **`CapabilityDetail`** type - `{ dirName, name, description, scope, body, rawFrontmatter, filePath, chatSessionId }`.
|
||||
- **`CapabilityPage`** - Legacy standalone two-panel page (used by `/skills`, `/tasks`, `/processes` routes, not by `/automation`).
|
||||
|
||||
## Backend
|
||||
|
||||
All four working routers (`skills`, `tools`, `tasks`, `processes`) follow the exact same pattern. Registered on the protected router in `hono.ts`.
|
||||
|
||||
### API Endpoints (per capability type)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/{type}` | List all items (native + global + user merged) |
|
||||
| `GET` | `/{type}/:name` | Get detail: frontmatter, body, rawYaml, filePath, chatSessionId |
|
||||
| `POST` | `/{type}` | Create new item (dir + stub `.md`) |
|
||||
| `DELETE` | `/{type}/:name` | Delete item directory |
|
||||
| `GET` | `/{type}/:name/chat` | Get saved chat messages |
|
||||
| `PUT` | `/{type}/:name/chat` | Save chat messages + session meta |
|
||||
| `DELETE` | `/{type}/:name/chat` | Delete chat directory |
|
||||
|
||||
Route files:
|
||||
- `src/servers/api/skills/skills.ts`
|
||||
- `src/servers/api/tools/tools.ts`
|
||||
- `src/servers/api/tasks/tasks.ts` (also parses `trigger:` block from frontmatter)
|
||||
- `src/servers/api/processes/processes.ts`
|
||||
|
||||
### Task Logs
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/task-logs` | List all log metadata (newest first) |
|
||||
| `GET` | `/task-logs/:filename` | Get full log with messages |
|
||||
|
||||
Route file: `src/servers/api/task-logs/task-logs.ts`
|
||||
|
||||
## Storage Model
|
||||
|
||||
Everything is stored on the filesystem. No database tables.
|
||||
|
||||
```
|
||||
DATA_PATH/
|
||||
skills/ <- global scope
|
||||
{skill-name}/
|
||||
SKILL.md <- frontmatter + markdown body
|
||||
chat/
|
||||
meta.json <- { id: sessionId }
|
||||
messages.json <- ChatMessage[]
|
||||
tasks/ <- global scope
|
||||
{task-name}/TASK.md
|
||||
tools/ <- global scope
|
||||
{tool-name}/TOOL.md
|
||||
processes/ <- global scope
|
||||
{process-name}/PROCESS.md
|
||||
{user-email}/ <- user scope
|
||||
skills/
|
||||
tasks/
|
||||
tools/
|
||||
processes/
|
||||
logs/tasks/ <- task execution logs
|
||||
{timestamp}-{dirName}.json
|
||||
|
||||
SEED_PATH/ <- native scope (read-only, shipped with app)
|
||||
skills/ (sharp, whisper-cpp, google-mail-api, ffmpeg, mutagen, fizzy-cli, mlxaudio)
|
||||
tasks/ (convert-to-mp3, sync-gmail-inbox, tiktok-trends, transcribe-audio-file, ...)
|
||||
tools/ (apify, browser, email-db, ffmpeg, gmail, ocr, web-fetch, web-search, ...)
|
||||
```
|
||||
|
||||
Path helpers: `src/servers/data-path.ts` (`getNativeSkillsDir`, `getGlobalSkillsDir`, `getUserSkillsDir`, etc.)
|
||||
|
||||
### Scope & Permissions
|
||||
|
||||
Three tiers with override resolution: **user > global > native**.
|
||||
|
||||
- `native`: seed directory, read-only, shipped with the app
|
||||
- `global`: shared data directory, writable by Super Admin
|
||||
- `user`: per-user directory, writable by the owning user
|
||||
|
||||
Regular users (`Member`) can only create/delete `user`-scoped items. `Super Admin` can also create/delete `global` items.
|
||||
|
||||
## Markdown File Formats
|
||||
|
||||
### TASK.md
|
||||
```yaml
|
||||
---
|
||||
name: Task Name
|
||||
description: What the task does.
|
||||
version: 1
|
||||
author: pastilhas
|
||||
tags: [tag1, tag2]
|
||||
skills: [skill-name] # optional, skills the agent can consult
|
||||
tools: [tool_name] # optional, tools the agent can call
|
||||
trigger: # optional, when omitted: only runnable from Automation page
|
||||
- type: file
|
||||
extensions: [mp3, flac]
|
||||
- type: directory
|
||||
inputs: # optional, parameters the user fills in before running
|
||||
- name: param_name
|
||||
type: string # string | number | boolean | select
|
||||
required: true
|
||||
default: some value
|
||||
---
|
||||
|
||||
(Agent instructions in markdown)
|
||||
```
|
||||
|
||||
### SKILL.md
|
||||
```yaml
|
||||
---
|
||||
name: skill-name
|
||||
description: When to use this skill.
|
||||
---
|
||||
|
||||
(Knowledge base / reference documentation in markdown)
|
||||
```
|
||||
|
||||
### TOOL.md
|
||||
```yaml
|
||||
---
|
||||
name: tool_name
|
||||
label: Tool Display Name
|
||||
description: What the tool does.
|
||||
language: typescript # typescript | bash | python
|
||||
inputs:
|
||||
param_name:
|
||||
type: string # string | number | boolean | enum | object
|
||||
description: What this parameter is for.
|
||||
optional: true
|
||||
sensitive: true
|
||||
---
|
||||
|
||||
(Usage notes, output format, examples in markdown)
|
||||
```
|
||||
|
||||
### PROCESS.md
|
||||
Same structure as SKILL.md (name + description frontmatter, markdown body).
|
||||
|
||||
## Task Execution Flow
|
||||
|
||||
1. User clicks "Run" on a task in `CapabilityDetailView`
|
||||
2. `parseInputs()` extracts `inputs:` from YAML frontmatter
|
||||
3. If inputs exist: shows input form dialog; if none: runs immediately
|
||||
4. Opens `TaskRunnerModal` with prompt: `"Read the task instructions at {filePath} and execute them"` (+ input values if any)
|
||||
5. Modal connects to Pi agent WebSocket via `usePiChat()`
|
||||
6. Agent reads the `TASK.md`, resolves skill/tool references, executes in the user's sandboxed container
|
||||
7. `task-logger.ts` (`createTaskLog` -> `appendToLog` -> `finalizeLog`) writes execution log to `DATA_PATH/{email}/logs/tasks/`
|
||||
|
||||
## Task Trigger Integration
|
||||
|
||||
Tasks with `trigger:` in their frontmatter appear in the file browser context menu:
|
||||
- `type: file` + `extensions: [mp3]` -> right-click on `.mp3` files shows this task
|
||||
- `type: directory` -> right-click on directories shows this task
|
||||
|
||||
Implemented via `useTasks` hook in the `officerdev` workspace, which provides `getMatchingTasks(fileName, entryType)`.
|
||||
|
||||
## Seed Sync (Startup)
|
||||
|
||||
On server startup (`bootstrap.ts`):
|
||||
- `syncSeedSkills()` and `syncSeedTools()` copy/update native seeds to global based on `version:` in frontmatter
|
||||
- `syncSeedTasks()` exists in code but is **not called** in bootstrap
|
||||
|
||||
## Legacy Routes
|
||||
|
||||
Standalone pages still exist using the older `CapabilityPage` component:
|
||||
- `/skills` -> `CapabilityPage` (two-panel, list + detail/chat)
|
||||
- `/tasks` -> `CapabilityPage`
|
||||
- `/processes` -> `CapabilityPage`
|
||||
|
||||
These share the same API endpoints but use the single-component layout instead of the workspace view.
|
||||
+294
-291
@@ -44,15 +44,12 @@ const PI_PROVIDERS: { key: string; piId: string }[] = [
|
||||
{ key: 'MiniMax', piId: 'minimax' },
|
||||
{ key: 'Hugging Face', piId: 'huggingface' },
|
||||
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
|
||||
{ key: 'OpenCode Zen', piId: 'zai' },
|
||||
{ key: 'OpenCode Zen', piId: 'opencode' },
|
||||
{ key: 'ZAI', piId: 'zai' },
|
||||
{ key: 'Cerebras', piId: 'cerebras' },
|
||||
];
|
||||
|
||||
type ProbeState =
|
||||
| { step: 'url' }
|
||||
| { step: 'probing' }
|
||||
| { step: 'auth'; probe: ProbeResult }
|
||||
| { step: 'saving' };
|
||||
type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' };
|
||||
|
||||
export const AIHarnessesSection = () => {
|
||||
const client = useClient();
|
||||
@@ -119,8 +116,7 @@ export const AIHarnessesSection = () => {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const getStoredMasked = (piId: string) =>
|
||||
storedKeys.find((k) => k.provider === piId)?.value ?? '';
|
||||
const getStoredMasked = (piId: string) => storedKeys.find((k) => k.provider === piId)?.value ?? '';
|
||||
|
||||
const saveApiKey = async (piId: string) => {
|
||||
const value = keyInputs[piId];
|
||||
@@ -142,7 +138,7 @@ export const AIHarnessesSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
|
||||
const disconnectProvider = async (provider: (typeof PI_PROVIDERS)[number]) => {
|
||||
await client.put('/server-settings/pi-mono/api-keys', { provider: provider.piId, value: '' });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_MONO_REMOTE_HEALTH'] });
|
||||
@@ -160,7 +156,9 @@ export const AIHarnessesSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleProbe = async (auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string }) => {
|
||||
const handleProbe = async (
|
||||
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string },
|
||||
) => {
|
||||
setProbeState({ step: 'probing' });
|
||||
try {
|
||||
const result = await client.post<ProbeResult>('/server-settings/pi-mono/local-providers/probe', {
|
||||
@@ -185,9 +183,10 @@ export const AIHarnessesSection = () => {
|
||||
};
|
||||
|
||||
const handleAuthSubmit = async (probe: ProbeResult) => {
|
||||
const auth = probe.authType === 'basic'
|
||||
? { type: 'basic' as const, username: authUsername, password: authPassword }
|
||||
: { type: 'api-key' as const, apiKey: authApiKey };
|
||||
const auth =
|
||||
probe.authType === 'basic'
|
||||
? { type: 'basic' as const, username: authUsername, password: authPassword }
|
||||
: { type: 'api-key' as const, apiKey: authApiKey };
|
||||
|
||||
// Re-probe with credentials to verify they work
|
||||
setProbeState({ step: 'probing' });
|
||||
@@ -276,302 +275,306 @@ export const AIHarnessesSection = () => {
|
||||
</div>
|
||||
{piMonoVersion?.version && (
|
||||
<>
|
||||
{/* Local Providers */}
|
||||
<div className="mt-6 text-xs text-duck-dark/50">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{localProviders.map((lp: LocalProviderEntry) => (
|
||||
<div key={lp.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-green-500' : 'bg-red-500') : 'bg-duck-dark/20'}`}
|
||||
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
|
||||
/>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
|
||||
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLocalProvider(lp.id)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||
title="Remove provider"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{addingLocal ? (
|
||||
<div className="flex flex-col gap-2 max-w-sm">
|
||||
{/* Step 1: Name + URL inputs stacked */}
|
||||
<Input
|
||||
type="text"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Name (e.g. My Ollama)"
|
||||
value={localName}
|
||||
onChange={(ev) => setLocalName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') resetLocalForm();
|
||||
}}
|
||||
disabled={probeState.step !== 'url'}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
type="url"
|
||||
className="h-8 text-xs"
|
||||
placeholder="http://localhost:11434"
|
||||
value={localUrl}
|
||||
onChange={(ev) => setLocalUrl(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') resetLocalForm();
|
||||
if (ev.key === 'Enter' && localUrl.trim() && probeState.step === 'url') handleProbe();
|
||||
}}
|
||||
disabled={probeState.step !== 'url'}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{probeState.step === 'url' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||
disabled={!localUrl.trim()}
|
||||
onClick={() => handleProbe()}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
{(probeState.step === 'probing' || probeState.step === 'saving') && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-duck-teal shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetLocalForm}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Auth form (if needed) */}
|
||||
{probeState.step === 'auth' && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">
|
||||
{probeState.probe.name} requires authentication
|
||||
</span>
|
||||
{probeState.probe.authType === 'basic' ? (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Username"
|
||||
value={authUsername}
|
||||
onChange={(ev) => setAuthUsername(ev.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Password"
|
||||
value={authPassword}
|
||||
onChange={(ev) => setAuthPassword(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && authUsername && authPassword) handleAuthSubmit(probeState.probe);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||
disabled={!authUsername || !authPassword}
|
||||
onClick={() => handleAuthSubmit(probeState.probe)}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-8 text-xs"
|
||||
placeholder="API Key"
|
||||
value={authApiKey}
|
||||
onChange={(ev) => setAuthApiKey(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && authApiKey) handleAuthSubmit(probeState.probe);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||
disabled={!authApiKey}
|
||||
onClick={() => handleAuthSubmit(probeState.probe)}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit cursor-pointer"
|
||||
onClick={() => setAddingLocal(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Connect Local Provider
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote Providers */}
|
||||
<div className="mt-6 text-xs text-duck-dark/50">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{connectedProviders.map((provider) => (
|
||||
<div key={provider.piId} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Local Providers */}
|
||||
<div className="mt-6 text-xs text-duck-dark/50">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{localProviders.map((lp: LocalProviderEntry) => (
|
||||
<div key={lp.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${
|
||||
remoteHealth[provider.piId] === true
|
||||
? 'bg-green-500'
|
||||
: remoteHealth[provider.piId] === false
|
||||
? 'bg-red-500'
|
||||
: 'bg-duck-dark/20'
|
||||
}`}
|
||||
title={
|
||||
remoteHealth[provider.piId] === true
|
||||
? 'Valid'
|
||||
: remoteHealth[provider.piId] === false
|
||||
? 'Invalid'
|
||||
: 'Unknown'
|
||||
}
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-green-500' : 'bg-red-500') : 'bg-duck-dark/20'}`}
|
||||
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
|
||||
/>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{provider.key}</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
|
||||
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
|
||||
title="Edit key"
|
||||
>
|
||||
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => disconnectProvider(provider)}
|
||||
onClick={() => removeLocalProvider(lp.id)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||
title="Disconnect provider"
|
||||
title="Remove provider"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
{editingProvider === provider.key && (
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<Input
|
||||
type="password"
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={getStoredMasked(provider.piId) || 'Enter API key'}
|
||||
value={keyInputs[provider.piId] ?? ''}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
))}
|
||||
{addingLocal ? (
|
||||
<div className="flex flex-col gap-2 max-w-sm">
|
||||
{/* Step 1: Name + URL inputs stacked */}
|
||||
<Input
|
||||
type="text"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Name (e.g. My Ollama)"
|
||||
value={localName}
|
||||
onChange={(ev) => setLocalName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') resetLocalForm();
|
||||
}}
|
||||
disabled={probeState.step !== 'url'}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
type="url"
|
||||
className="h-8 text-xs"
|
||||
placeholder="http://localhost:11434"
|
||||
value={localUrl}
|
||||
onChange={(ev) => setLocalUrl(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') resetLocalForm();
|
||||
if (ev.key === 'Enter' && localUrl.trim() && probeState.step === 'url') handleProbe();
|
||||
}}
|
||||
disabled={probeState.step !== 'url'}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{probeState.step === 'url' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||
disabled={!localUrl.trim()}
|
||||
onClick={() => handleProbe()}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
{(probeState.step === 'probing' || probeState.step === 'saving') && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-duck-teal shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
|
||||
onClick={() => saveApiKey(provider.piId)}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||
title="Save key"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProvider(null)}
|
||||
onClick={resetLocalForm}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{editingProvider && (() => {
|
||||
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
|
||||
if (!provider || connectedProviders.some((cp) => cp.piId === provider.piId)) return null;
|
||||
return (
|
||||
<div key={provider.piId} className="flex items-center gap-2">
|
||||
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">{provider.key}</label>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Enter API key"
|
||||
value={keyInputs[provider.piId] ?? ''}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
|
||||
onClick={() => saveApiKey(provider.piId)}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||
title="Save key"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProvider(null)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{/* Step 2: Auth form (if needed) */}
|
||||
{probeState.step === 'auth' && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">
|
||||
{probeState.probe.name} requires authentication
|
||||
</span>
|
||||
{probeState.probe.authType === 'basic' ? (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Username"
|
||||
value={authUsername}
|
||||
onChange={(ev) => setAuthUsername(ev.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Password"
|
||||
value={authPassword}
|
||||
onChange={(ev) => setAuthPassword(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && authUsername && authPassword)
|
||||
handleAuthSubmit(probeState.probe);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||
disabled={!authUsername || !authPassword}
|
||||
onClick={() => handleAuthSubmit(probeState.probe)}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-8 text-xs"
|
||||
placeholder="API Key"
|
||||
value={authApiKey}
|
||||
onChange={(ev) => setAuthApiKey(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && authApiKey) handleAuthSubmit(probeState.probe);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||
disabled={!authApiKey}
|
||||
onClick={() => handleAuthSubmit(probeState.probe)}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{unconnectedProviders.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1 w-fit cursor-pointer"
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Connect Provider
|
||||
</Button>
|
||||
)}
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit cursor-pointer"
|
||||
onClick={() => setAddingLocal(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Connect Local Provider
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote Providers */}
|
||||
<div className="mt-6 text-xs text-duck-dark/50">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{connectedProviders.map((provider) => (
|
||||
<div key={provider.piId} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${
|
||||
remoteHealth[provider.piId] === true
|
||||
? 'bg-green-500'
|
||||
: remoteHealth[provider.piId] === false
|
||||
? 'bg-red-500'
|
||||
: 'bg-duck-dark/20'
|
||||
}`}
|
||||
title={
|
||||
remoteHealth[provider.piId] === true
|
||||
? 'Valid'
|
||||
: remoteHealth[provider.piId] === false
|
||||
? 'Invalid'
|
||||
: 'Unknown'
|
||||
}
|
||||
/>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{provider.key}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
|
||||
title="Edit key"
|
||||
>
|
||||
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => disconnectProvider(provider)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||
title="Disconnect provider"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
{editingProvider === provider.key && (
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<Input
|
||||
type="password"
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={getStoredMasked(provider.piId) || 'Enter API key'}
|
||||
value={keyInputs[provider.piId] ?? ''}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
|
||||
onClick={() => saveApiKey(provider.piId)}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||
title="Save key"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProvider(null)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{editingProvider &&
|
||||
(() => {
|
||||
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
|
||||
if (!provider || connectedProviders.some((cp) => cp.piId === provider.piId)) return null;
|
||||
return (
|
||||
<div key={provider.piId} className="flex items-center gap-2">
|
||||
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
|
||||
{provider.key}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Enter API key"
|
||||
value={keyInputs[provider.piId] ?? ''}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
|
||||
onClick={() => saveApiKey(provider.piId)}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||
title="Save key"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProvider(null)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{unconnectedProviders.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1 w-fit cursor-pointer"
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Connect Provider
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
|
||||
<CommandInput placeholder="Search providers..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No providers found.</CommandEmpty>
|
||||
<CommandGroup heading="Available Providers">
|
||||
{unconnectedProviders.map((provider) => (
|
||||
<CommandItem
|
||||
key={provider.key}
|
||||
onSelect={() => {
|
||||
setEditingProvider(provider.key);
|
||||
setCommandOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{provider.key}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
|
||||
<CommandInput placeholder="Search providers..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No providers found.</CommandEmpty>
|
||||
<CommandGroup heading="Available Providers">
|
||||
{unconnectedProviders.map((provider) => (
|
||||
<CommandItem
|
||||
key={provider.key}
|
||||
onSelect={() => {
|
||||
setEditingProvider(provider.key);
|
||||
setCommandOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{provider.key}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
opencode: 'OpenCode Zen',
|
||||
zai: 'ZAI',
|
||||
google: 'Google',
|
||||
groq: 'Groq',
|
||||
mistral: 'Mistral',
|
||||
@@ -52,12 +53,42 @@ const groups: SettingsSectionGroup[] = [
|
||||
label: 'AI',
|
||||
icon: Bot,
|
||||
sections: [
|
||||
{ key: 'ai-harnesses', icon: Terminal, title: 'Providers', description: 'Remote and local AI providers', content: <AIHarnessesSection /> },
|
||||
{ key: 'model-visibility', icon: Eye, title: 'Models', description: 'Enable or disable models', content: <ModelVisibilitySection /> },
|
||||
{ key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: <ChatDefaultsSection /> },
|
||||
{ key: 'tts', icon: Volume2, title: 'Text to Speech', description: 'TTS provider and voice', content: <TTSSection /> },
|
||||
{
|
||||
key: 'ai-harnesses',
|
||||
icon: Terminal,
|
||||
title: 'Providers',
|
||||
description: 'Remote and local AI providers',
|
||||
content: <AIHarnessesSection />,
|
||||
},
|
||||
{
|
||||
key: 'model-visibility',
|
||||
icon: Eye,
|
||||
title: 'Models',
|
||||
description: 'Enable or disable models',
|
||||
content: <ModelVisibilitySection />,
|
||||
},
|
||||
{
|
||||
key: 'chat-defaults',
|
||||
icon: Terminal,
|
||||
title: 'Chat Defaults',
|
||||
description: 'Model, prompt, and temperature',
|
||||
content: <ChatDefaultsSection />,
|
||||
},
|
||||
{
|
||||
key: 'tts',
|
||||
icon: Volume2,
|
||||
title: 'Text to Speech',
|
||||
description: 'TTS provider and voice',
|
||||
content: <TTSSection />,
|
||||
},
|
||||
{ key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: <STTSection /> },
|
||||
{ key: 'ocr', icon: ScanText, title: 'OCR', description: 'Vision model for text extraction', content: <OCRSection /> },
|
||||
{
|
||||
key: 'ocr',
|
||||
icon: ScanText,
|
||||
title: 'OCR',
|
||||
description: 'Vision model for text extraction',
|
||||
content: <OCRSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -142,7 +173,10 @@ const SystemTerminalPanel = () => {
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
|
||||
<button onClick={close} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
|
||||
<button
|
||||
onClick={close}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -325,7 +359,9 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">{label}</span>
|
||||
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -376,7 +412,7 @@ function ModelVisibilitySection() {
|
||||
const providers = useMemo(() => providerGroups.map((g) => g.provider), [providerGroups]);
|
||||
|
||||
// Auto-select first provider if none selected or stale
|
||||
const selected = providers.includes(activeProvider) ? activeProvider : providers[0] ?? '';
|
||||
const selected = providers.includes(activeProvider) ? activeProvider : (providers[0] ?? '');
|
||||
|
||||
const currentGroup = providerGroups.find((g) => g.provider === selected);
|
||||
|
||||
@@ -430,7 +466,11 @@ function ModelVisibilitySection() {
|
||||
}, []);
|
||||
|
||||
if (providerGroups.length === 0) {
|
||||
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">No models available. Configure API keys in AI Settings.</p>;
|
||||
return (
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
No models available. Configure API keys in AI Settings.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -463,17 +503,35 @@ function ModelVisibilitySection() {
|
||||
|
||||
{/* Enabled section */}
|
||||
<DropZone label="Enabled" onDrop={enableModel}>
|
||||
{enabled.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>}
|
||||
{enabled.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>
|
||||
)}
|
||||
{enabled.map((m) => (
|
||||
<ModelPill key={m.key} label={m.name} modelKey={m.key} enabled onAction={disableModel} onDragStart={onDragStart} />
|
||||
<ModelPill
|
||||
key={m.key}
|
||||
label={m.name}
|
||||
modelKey={m.key}
|
||||
enabled
|
||||
onAction={disableModel}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
{/* Disabled section */}
|
||||
<DropZone label="Disabled" onDrop={disableModel}>
|
||||
{disabled.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span>}
|
||||
{disabled.length === 0 && (
|
||||
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span>
|
||||
)}
|
||||
{disabled.map((m) => (
|
||||
<ModelPill key={m.key} label={m.name} modelKey={m.key} enabled={false} onAction={enableModel} onDragStart={onDragStart} />
|
||||
<ModelPill
|
||||
key={m.key}
|
||||
label={m.name}
|
||||
modelKey={m.key}
|
||||
enabled={false}
|
||||
onAction={enableModel}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</DropZone>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user