wip: remove opencode, searxng, resources; fix user settings read

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 07:27:19 +00:00
co-authored by Claude Opus 4.6
parent 5925ac49a1
commit 7bbcccabf1
46 changed files with 325 additions and 2037 deletions
-67
View File
@@ -1,67 +0,0 @@
/**
* Migration script: per-user settings.json and state.json → PostgreSQL
*
* Reads from $DATA_PATH/{email}/settings/settings.json and state/state.json
* Writes to user_settings and user_state tables
*
* Usage: bun run scripts/migrate-user-settings-to-pg.ts
*/
import { join } from 'node:path';
import { readdirSync } from 'node:fs';
import { getUserByEmail, setUserSettings, patchUserState } from 'officerdb';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
async function readJson<T>(path: string): Promise<T | null> {
try {
const file = Bun.file(path);
if (!(await file.exists())) return null;
return (await file.json()) as T;
} catch {
return null;
}
}
async function migrate() {
console.log(`[migrate] Scanning ${DATA_PATH} for user data dirs`);
// User data dirs are named by email (contain @)
const entries = readdirSync(DATA_PATH, { withFileTypes: true });
const userDirs = entries.filter((e) => e.isDirectory() && e.name.includes('@'));
console.log(`[migrate] Found ${userDirs.length} user dirs: ${userDirs.map((d) => d.name).join(', ')}`);
for (const dir of userDirs) {
const email = dir.name;
const dbUser = await getUserByEmail(email);
if (!dbUser) {
console.warn(` [skip] ${email} — no matching user in DB`);
continue;
}
// Settings
const settingsPath = join(DATA_PATH, email, 'settings', 'settings.json');
const settings = await readJson<Record<string, unknown>>(settingsPath);
if (settings && Object.keys(settings).length > 0) {
await setUserSettings(dbUser.id, settings);
console.log(` [settings] ${email}${Object.keys(settings).length} keys`);
}
// State
const statePath = join(DATA_PATH, email, 'state', 'state.json');
const state = await readJson<Record<string, unknown>>(statePath);
if (state && Object.keys(state).length > 0) {
await patchUserState(dbUser.id, state);
console.log(` [state] ${email}${Object.keys(state).length} keys`);
}
}
console.log('[migrate] Done!');
process.exit(0);
}
migrate().catch((err) => {
console.error('[migrate] Failed:', err);
process.exit(1);
});
-51
View File
@@ -1,51 +0,0 @@
# Resource Configuration Guide
You are an AI assistant helping users configure **resources** — external services and APIs that Officer connects to.
## What is a Resource?
A resource represents an external service (e.g., a TTS server, an STT API, an OCR endpoint). Each resource has:
- A `RESOURCE.md` file with frontmatter metadata (name, description) and a markdown body
- A `config.json` file with flat key-value connection settings (all strings)
## config.json Format
The config is a flat JSON object where every value is a string. Empty string means "not set".
Base fields (always present):
- `url` — the service endpoint URL
- `api_key` — API key or token for authentication
- `username` — username for basic auth
- `password` — password for basic auth
Additional fields vary per resource (e.g., `provider`, `model`, `voice`).
Example:
```json
{
"url": "http://localhost:8000",
"api_key": "",
"username": "",
"password": "",
"provider": "openai",
"model": "tts-1",
"voice": "alloy"
}
```
## How to Help the User
1. **Ask what service they're connecting to** — provider name, URL, auth method
2. **Fill in the config.json** — write the file with the values they provide
3. **Explain each field** — tell them what each key does and what format it expects
4. **For new resources**, also write the `RESOURCE.md` with an appropriate name and description in the frontmatter
## RESOURCE.md Format
```markdown
---
name: Human Readable Name
description: One-line description of what this resource does
---
Optional longer markdown body with usage notes, compatible providers, etc.
```
@@ -1,6 +0,0 @@
# Optical Character Recognition — OCR API
- **Type:** OCR
- **Port:** 8082
- **Description:** HTTP server for optical character recognition. Compatible with OpenAI-style vision APIs. Configure the URL and model in the connection settings below.
- **Verify:** `curl -sf {url}/v1/models`
-6
View File
@@ -1,6 +0,0 @@
# Speech to Text — Transcription API
- **Type:** STT
- **Port:** 8080
- **Description:** HTTP server for speech-to-text transcription. Compatible with the whisper.cpp server API. Configure the URL in the connection settings below.
- **Verify:** `curl -sf {url}/health`
-6
View File
@@ -1,6 +0,0 @@
# Text to Speech — Synthesis API
- **Type:** TTS
- **Port:** 8000
- **Description:** HTTP server for text-to-speech synthesis. Compatible with the OpenAI audio/speech API (mlx-audio, Kokoro, etc.) and ElevenLabs. Configure the URL and credentials in the connection settings below.
- **Verify:** `curl -sf {url}/v1/models`
@@ -1,5 +0,0 @@
---
name: Optical Character Recognition
description: HTTP server for optical character recognition
---
Compatible with OpenAI-style vision APIs for extracting text from images.
@@ -1,7 +0,0 @@
{
"url": "",
"api_key": "",
"username": "",
"password": "",
"model": ""
}
@@ -1,5 +0,0 @@
---
name: Speech to Text
description: HTTP server for speech-to-text transcription
---
Compatible with the whisper.cpp server API and OpenAI-compatible transcription endpoints.
@@ -1,6 +0,0 @@
{
"url": "",
"api_key": "",
"username": "",
"password": ""
}
@@ -1,5 +0,0 @@
---
name: Text to Speech
description: HTTP server for text-to-speech synthesis
---
Compatible with OpenAI audio/speech API (mlx-audio, Kokoro, etc.) and ElevenLabs.
@@ -1,9 +0,0 @@
{
"url": "",
"api_key": "",
"username": "",
"password": "",
"provider": "",
"model": "",
"voice": ""
}
-4
View File
@@ -44,10 +44,6 @@ export function App() {
path="/settings/system"
element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />}
/>
<Route
path="/settings/resources"
element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />}
/>
<Route
path="/settings/users"
element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />}
@@ -2,15 +2,9 @@ import { useState } from 'react';
import { Terminal, Copy, Check } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Card } from '@/components/Card';
import { useClient } from 'hooks/useClient';
type Harnesses = {
claudeCode: boolean;
opencode: boolean;
};
type AIHarnessesCardProps = {
onNext: () => void;
onBack: () => void;
@@ -20,30 +14,14 @@ type AIHarnessesCardProps = {
export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCardProps) => {
const client = useClient();
const queryClient = useQueryClient();
const [harnesses, setHarnesses] = useState<Harnesses>({ claudeCode: false, opencode: false });
const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean }>({
claudeCode: false,
opencode: false,
});
const [installing, setInstalling] = useState(false);
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
type OpencodeAuthInfo = { authenticated: boolean; providers: string[] };
const { data: claudeVersion, isLoading: claudeLoading } = useQuery({
queryKey: ['CLAUDE_CODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/claude-code/version'),
enabled: harnesses.claudeCode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const { data: opencodeVersion, isLoading: opencodeLoading } = useQuery({
queryKey: ['OPENCODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/opencode/version'),
enabled: harnesses.opencode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
@@ -57,30 +35,13 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
});
const { data: opencodeAuth } = useQuery({
queryKey: ['OPENCODE_AUTH'],
queryFn: () => client.get<OpencodeAuthInfo>('/server-settings/opencode/auth'),
enabled: !!opencodeVersion?.version,
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
});
const installClaude = async () => {
setInstalling((prev) => ({ ...prev, claudeCode: true }));
setInstalling(true);
try {
const result = await client.post<VersionInfo>('/server-settings/claude-code/install');
queryClient.setQueryData(['CLAUDE_CODE_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, claudeCode: false }));
}
};
const installOpencode = async () => {
setInstalling((prev) => ({ ...prev, opencode: true }));
try {
const result = await client.post<VersionInfo>('/server-settings/opencode/install');
queryClient.setQueryData(['OPENCODE_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, opencode: false }));
setInstalling(false);
}
};
@@ -112,15 +73,10 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar
</div>
);
const claudeReady =
!harnesses.claudeCode || (!!claudeVersion?.version && !!claudeVersion?.globalPath && !!claudeAuth?.authenticated);
const opencodeReady =
!harnesses.opencode ||
(!!opencodeVersion?.version && !!opencodeVersion?.globalPath && !!opencodeAuth?.authenticated);
const canProceed = (harnesses.claudeCode || harnesses.opencode) && claudeReady && opencodeReady;
const claudeReady = !!claudeVersion?.version && !!claudeVersion?.globalPath && !!claudeAuth?.authenticated;
const handleNext = async () => {
await saveSettings({ aiHarnesses: harnesses, onboardingComplete: true });
await saveSettings({ aiHarnesses: { claudeCode: true }, onboardingComplete: true });
onNext();
};
@@ -130,70 +86,11 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar
<Terminal className="h-5 w-5 text-duck-forest" />
<h2 className="text-xl font-bold text-duck-dark">AI Harnesses</h2>
</div>
<p className="text-duck-dark/70 text-sm mb-6">Which AI coding tools do you use?</p>
<p className="text-duck-dark/70 text-sm mb-6">Set up Claude Code for AI-assisted development.</p>
<div className="flex flex-col gap-4">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={harnesses.opencode}
onCheckedChange={(checked) => setHarnesses((prev) => ({ ...prev, opencode: !!checked }))}
/>
<span className="text-sm font-medium text-duck-dark">Opencode</span>
</label>
{harnesses.opencode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{opencodeLoading ? (
'Checking version...'
) : opencodeVersion?.version ? (
<>
<div>{opencodeVersion.version}</div>
<div>{opencodeVersion.path}</div>
{opencodeAuth && (
<div className={`mt-1 ${opencodeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{opencodeAuth.authenticated ? (
`Logged in (${opencodeAuth.providers.join(', ')})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/opencode/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!opencodeVersion.globalPath && opencodeVersion.path && (
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installOpencode}
disabled={installing.opencode}
>
{installing.opencode ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={harnesses.claudeCode}
onCheckedChange={(checked) => setHarnesses((prev) => ({ ...prev, claudeCode: !!checked }))}
/>
<span className="text-sm font-medium text-duck-dark">Claude Code</span>
</label>
{harnesses.claudeCode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{claudeLoading ? (
'Checking version...'
@@ -227,14 +124,13 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installOpencode}
disabled={installing.opencode}
onClick={installClaude}
disabled={installing}
>
{installing.opencode ? 'Installing...' : 'Install'}
{installing ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
</div>
</div>
@@ -242,7 +138,7 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar
<Button variant="outline" onClick={onBack}>
Back
</Button>
<Button disabled={!canProceed} onClick={handleNext}>
<Button disabled={!claudeReady} onClick={handleNext}>
Complete Setup
</Button>
</div>
@@ -19,7 +19,6 @@ import { AIModels } from './ProfileSettings/AIModels';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode Zen',
zai: 'ZAI',
google: 'Google',
groq: 'Groq',
@@ -1,162 +0,0 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Download, Circle, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
type AppStatus = {
id: string;
name: string;
description: string;
installed: boolean;
version: string | null;
running: boolean | null;
hasInstall: boolean;
hasUpdate: boolean;
manualInstallCommand: string | null;
manualUpdateCommand: string | null;
};
const CopyCommand = ({ command }: { command: string }) => {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 dark:bg-foreground/5 rounded px-2 py-1 text-xs text-duck-dark/70 dark:text-foreground/70">{command}</code>
<button
type="button"
onClick={copy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />}
</button>
</div>
);
};
export const Applications = () => {
const client = useClient();
const queryClient = useQueryClient();
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
const { data: apps, isLoading } = useQuery({
queryKey: ['APPLICATIONS'],
queryFn: () => client.get<AppStatus[]>('/server-settings/applications'),
});
const runAction = async (id: string, action: 'install' | 'update') => {
setActionInProgress(id);
try {
await client.post<AppStatus>(`/server-settings/applications/${id}/${action}`);
await queryClient.invalidateQueries({ queryKey: ['APPLICATIONS'] });
toast.success(`${action === 'install' ? 'Installed' : 'Updated'} successfully`);
} catch (err) {
const message = err instanceof Error ? err.message : `${action} failed`;
toast.error(message);
} finally {
setActionInProgress(null);
}
};
const getManualCommand = (app: AppStatus): string | null => {
if (!app.installed) return app.manualInstallCommand;
return app.manualUpdateCommand ?? app.manualInstallCommand;
};
const hasAutoAction = (app: AppStatus): boolean => {
if (!app.installed) return app.hasInstall && !app.manualInstallCommand;
return app.hasUpdate && !(app.manualUpdateCommand ?? app.manualInstallCommand);
};
return (
<div className="h-full overflow-y-auto p-6">
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
{isLoading && <p className="text-sm text-duck-dark/50 dark:text-foreground/50">Checking applications...</p>}
{apps && (
<div className="flex flex-col gap-3">
{apps.map((app: AppStatus) => {
const manualCmd = getManualCommand(app);
const canAutoRun = hasAutoAction(app);
return (
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 dark:border-foreground/10 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-duck-dark dark:text-foreground">{app.name}</span>
{app.installed && (
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
{app.version}
</span>
)}
{!app.installed && (
<span className="text-xs bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/40 dark:text-foreground/40 rounded-full px-2 py-0.5">
Not installed
</span>
)}
{app.running !== null && (
<Circle
className={`h-2.5 w-2.5 ${app.running ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'}`}
/>
)}
</div>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-0.5">{app.description}</p>
</div>
<div className="shrink-0">
{canAutoRun && !app.installed && (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
disabled={actionInProgress === app.id}
onClick={() => runAction(app.id, 'install')}
>
{actionInProgress === app.id ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{actionInProgress === app.id ? 'Installing...' : 'Install'}
</Button>
)}
{canAutoRun && app.installed && (
<Button
size="sm"
variant="outline"
disabled={actionInProgress === app.id}
onClick={() => runAction(app.id, 'update')}
>
{actionInProgress === app.id ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{actionInProgress === app.id ? 'Updating...' : 'Update'}
</Button>
)}
</div>
</div>
{manualCmd && (
<div className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
{app.installed ? 'Update' : 'Install'} manually:
<CopyCommand command={manualCmd} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
@@ -1,127 +0,0 @@
import { useState } from 'react';
import { Server, Plus, Check, X, Search } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { useResources, type ResourceSummary } from 'state/useResources';
export const ResourceSidebar = () => {
const { resources, isLoading, createResource } = useResources();
const [selectedName, setSelectedName] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const query = search.toLowerCase();
const filtered = resources?.filter(
(r: ResourceSummary) =>
!query || r.name.toLowerCase().includes(query) || r.description?.toLowerCase().includes(query),
) ?? [];
const handleSelect = (dirName: string) => {
setSelectedName(dirName);
};
const handleCreate = async () => {
const name = newName.trim();
if (!name) return;
try {
const result = await createResource(name);
setCreating(false);
setNewName('');
setSelectedName(result.dirName);
} catch {
// error handled by client
}
};
return (
<div className="flex flex-col h-full">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center justify-between">
<span className="text-sm font-medium text-duck-dark/70">Resources</span>
{!creating && (
<button
onClick={() => setCreating(true)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<Plus className="h-4 w-4 text-duck-dark/50" />
</button>
)}
</div>
{creating && (
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5">
<input
value={newName}
onChange={(ev) => setNewName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleCreate();
}
if (ev.key === 'Escape') {
setCreating(false);
setNewName('');
}
}}
placeholder="Resource name..."
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
autoFocus
/>
<button
onClick={handleCreate}
disabled={!newName.trim()}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
>
<Check className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
onClick={() => { setCreating(false); setNewName(''); }}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
</button>
</div>
)}
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
<input
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search resources..."
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
</div>
<div className="overflow-y-auto flex-1">
{isLoading && <p className="text-xs text-duck-dark/50 px-4 py-2">Loading...</p>}
{filtered.map((r: ResourceSummary) => (
<button
key={r.dirName}
onClick={() => handleSelect(r.dirName)}
className={`w-full text-left px-4 py-3 border-b border-duck-dark/5 cursor-pointer transition-colors ${
selectedName === r.dirName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
}`}
>
<div className="flex items-center gap-2">
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0" />
<span className="text-sm font-medium text-duck-dark truncate">{r.name}</span>
<span
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
r.scope === 'global' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
}`}
>
{r.scope}
</span>
</div>
{r.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{r.description}</p>}
</button>
))}
{!isLoading && resources && resources.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No resources found</p>
)}
{!isLoading && resources && resources.length > 0 && filtered.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
)}
</div>
</div>
);
};
@@ -1,349 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { toast } from 'sonner';
import { Pencil, Trash2, Plus, X, Loader2, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { usePiChat, EmbeddableChat } from 'officerdev';
import { useResources, type ResourceDetail, type PingResult } from 'state/useResources';
import { FrontmatterBlock } from '../../CapabilityPage';
const isSensitiveKey = (key: string) => /key|secret|password|token/i.test(key);
type ConfigEditorProps = {
resourceName: string;
config: Record<string, string>;
onSaved: () => void;
};
const ConfigEditor = ({ resourceName, config, onSaved }: ConfigEditorProps) => {
const { saveConfig, pingResource } = useResources();
const configEntries = Object.entries(config);
const [fields, setFields] = useState<[string, string][]>(configEntries);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const [pinging, setPinging] = useState(false);
const [pingResult, setPingResult] = useState<PingResult | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
setFields(Object.entries(config));
}, [config]);
const hasUrl = fields.some(([k, v]) => k === 'url' && v);
const handleAddField = () => {
const key = newKey.trim();
if (!key || fields.some(([k]) => k === key)) return;
setFields([...fields, [key, newValue]]);
setNewKey('');
setNewValue('');
};
const handleRemoveField = (index: number) => {
setFields(fields.filter((_, i) => i !== index));
};
const handleFieldValue = (index: number, value: string) => {
setFields(fields.map((f, i) => (i === index ? [f[0]!, value] : f)));
};
const handlePing = async () => {
const url = fields.find(([k]) => k === 'url')?.[1];
if (!url) return;
setPinging(true);
setPingResult(null);
try {
const result = await pingResource(resourceName, url);
setPingResult(result);
} catch {
setPingResult({ reachable: false, latencyMs: null });
} finally {
setPinging(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const oldKeys = Object.keys(config);
const newKeys = new Set(fields.map(([k]) => k));
const patch: Record<string, string | null> = {};
for (const [key, value] of fields) {
patch[key] = value;
}
for (const key of oldKeys) {
if (!newKeys.has(key)) patch[key] = null;
}
await saveConfig(resourceName, patch);
onSaved();
toast.success('Configuration saved');
} catch {
toast.error('Failed to save configuration');
} finally {
setSaving(false);
}
};
return (
<div className="mb-6">
<h3 className="text-sm font-semibold text-duck-dark mb-3">Configuration</h3>
<div className="flex flex-col gap-2">
{fields.map(([key, value], index) => (
<div key={key} className="flex items-center gap-2">
<label className="text-xs text-duck-dark/50 w-28 shrink-0 truncate" title={key}>
{key}
</label>
<Input
value={value}
onChange={(ev) => handleFieldValue(index, ev.target.value)}
type={isSensitiveKey(key) ? 'password' : 'text'}
className="h-8 text-xs flex-1"
/>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 shrink-0 text-duck-dark/30 hover:text-red-500 cursor-pointer"
onClick={() => handleRemoveField(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Input
value={newKey}
onChange={(ev) => setNewKey(ev.target.value)}
placeholder="key"
className="h-8 text-xs w-28 shrink-0"
onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()}
/>
<Input
value={newValue}
onChange={(ev) => setNewValue(ev.target.value)}
placeholder="value"
className="h-8 text-xs flex-1"
onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()}
/>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 shrink-0 text-duck-dark/30 hover:text-duck-teal cursor-pointer"
onClick={handleAddField}
disabled={!newKey.trim()}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-2 mt-1">
{hasUrl && (
<Button variant="outline" size="sm" onClick={handlePing} disabled={pinging} className="text-xs cursor-pointer">
{pinging && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Test Connection
</Button>
)}
<Button size="sm" onClick={handleSave} disabled={saving} className="text-xs cursor-pointer">
{saving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save
</Button>
{pingResult && (
<span className={`text-xs ${pingResult.reachable ? 'text-green-600' : 'text-red-500'}`}>
{pingResult.reachable ? `Reachable (${pingResult.latencyMs}ms)` : 'Unreachable'}
</span>
)}
</div>
</div>
</div>
);
};
type ResourceChatProps = {
detail: ResourceDetail;
isNew?: boolean;
onResponseEnd: () => void;
};
const ResourceChat = ({ detail, isNew, onResponseEnd }: ResourceChatProps) => {
const promptFrontmatter = `<frontmatter>\nconfig file: ${detail.configPath}\nresource file: ${detail.filePath}\nguide: ${detail.guidePath}\n\nYou are helping configure a resource. Read the GUIDE.md for instructions on how to help. Read the RESOURCE.md for context about what this resource is. Write config values to the config.json file.\n</frontmatter>`;
const defaultInput = isNew
? 'Help me set up this new resource'
: 'Help me configure this resource';
const pi = usePiChat(undefined, undefined, { replaceUrl: false });
const onResponseEndRef = useRef(onResponseEnd);
onResponseEndRef.current = onResponseEnd;
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !pi.isGenerating) {
onResponseEndRef.current();
}
wasGenerating.current = pi.isGenerating;
}, [pi.isGenerating]);
return (
<EmbeddableChat
chat={pi}
defaultInput={defaultInput}
promptPrefix={promptFrontmatter}
className="h-full"
/>
);
};
export const Resources = () => {
const client = useClient();
const qc = useQueryClient();
const [selectedName] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [editing, setEditing] = useState(false);
const [isNew, setIsNew] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [showDetail, setShowDetail] = useState(false);
const { data: detail, refetch } = useQuery<ResourceDetail>({
queryKey: ['RESOURCES', selectedName],
queryFn: () => client.get<ResourceDetail>(`/server-settings/resources/${selectedName}`),
enabled: !!selectedName,
});
useEffect(() => {
if (selectedName) {
setShowDetail(true);
setEditing(false);
}
}, [selectedName]);
const handleDelete = async () => {
if (!selectedName) return;
try {
await client.delete(`/server-settings/resources/${selectedName}`);
setDeleteConfirm(false);
setEditing(false);
await qc.invalidateQueries({ queryKey: ['RESOURCES'] });
} catch {
toast.error('Failed to delete resource');
}
};
const canDelete = detail && detail.scope === 'global' && !detail.filePath.includes('/seed/');
if (!selectedName || !detail) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/40">Select a resource to view its configuration</p>
</div>
);
}
return (
<>
<div className="flex flex-col h-full">
{/* Detail panel */}
<div className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
<button
onClick={() => setShowDetail(false)}
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
>
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</button>
<span className="text-sm font-medium text-duck-dark/70 flex-1">{detail.name}</span>
<span
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
detail.scope === 'global' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
}`}
>
{detail.scope}
</span>
<button
onClick={() => setEditing((e) => !e)}
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
>
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
</button>
{canDelete && (
<button
onClick={() => setDeleteConfirm(true)}
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
>
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
</button>
)}
</div>
<div className="overflow-y-auto flex-1 p-6">
{detail.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
{detail.body && (
<article className="skill-md mb-6">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{detail.body}
</ReactMarkdown>
</article>
)}
<ConfigEditor
key={selectedName}
resourceName={selectedName}
config={detail.config}
onSaved={() => refetch()}
/>
</div>
</div>
{/* Chat panel */}
{editing && (
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
<button
onClick={() => setEditing(false)}
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
>
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</button>
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail.name} Chat</span>
<button
onClick={() => setEditing(false)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
</button>
</div>
<ResourceChat
key={detail.filePath}
detail={detail}
isNew={isNew}
onResponseEnd={() => {
refetch();
qc.invalidateQueries({ queryKey: ['RESOURCES'] });
}}
/>
</div>
)}
</div>
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
<DialogContent className="sm:max-w-md z-[700]">
<DialogHeader>
<DialogTitle>Delete Resource</DialogTitle>
<DialogDescription>
Are you sure you want to delete &quot;{detail.name}&quot;? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};
@@ -1,32 +0,0 @@
import { useMemo } from 'react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { Resources } from './Resources';
import { ResourceSidebar } from './ResourceSidebar';
const layout: LayoutNode = {
type: 'group',
id: 'resources-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 },
{ node: { type: 'panel', id: 'resources-right', appType: null }, size: 80 },
],
};
export const ResourceSettings = () => {
const panelComponents: PanelComponents = useMemo(
() => ({
'resources-left': ResourceSidebar,
'resources-right': Resources,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
);
};
@@ -44,7 +44,6 @@ 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: 'opencode' },
{ key: 'ZAI', piId: 'zai' },
{ key: 'Cerebras', piId: 'cerebras' },
];
@@ -1,7 +1,6 @@
export * from './ProfileSettings';
export * from './SystemSettings';
export * from './AISettings';
export * from './ResourceSettings';
export * from './UserSettings';
export * from './IntegrationsSettings';
export * from './AppsSettings';
@@ -8,7 +8,9 @@ import { users } from './auth';
// ── Tasks ──
// Complex frontmatter: inputs, outputs, dependencies, triggers, config, tags, tools, skills
export const tasks = pgTable('tasks', {
export const tasks = pgTable(
'tasks',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
@@ -27,16 +29,20 @@ export const tasks = pgTable('tasks', {
trigger: jsonb('trigger'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
},
(table) => [
unique('uq_tasks_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_tasks_scope').on(table.scope),
index('idx_tasks_user').on(table.userId),
]);
],
);
// ── Skills ──
// Minimal frontmatter: name, description only. Rich markdown body.
export const skills = pgTable('skills', {
export const skills = pgTable(
'skills',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
@@ -47,16 +53,20 @@ export const skills = pgTable('skills', {
version: integer('version').notNull().default(1),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
},
(table) => [
unique('uq_skills_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_skills_scope').on(table.scope),
index('idx_skills_user').on(table.userId),
]);
],
);
// ── Processes ──
// Same shape as skills. Represents documented workflows.
export const processes = pgTable('processes', {
export const processes = pgTable(
'processes',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
@@ -67,35 +77,20 @@ export const processes = pgTable('processes', {
version: integer('version').notNull().default(1),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
},
(table) => [
unique('uq_processes_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_processes_scope').on(table.scope),
index('idx_processes_user').on(table.userId),
]);
// ── Resources ──
// Has separate config (key-value for external service settings). No user scope.
export const resources = pgTable('resources', {
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
config: jsonb('config').$type<Record<string, string>>().notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_resources_scope_dir').on(table.scope, table.dirName),
index('idx_resources_scope').on(table.scope),
]);
],
);
// ── Tools ──
// Has implementation code, language, structured input params, label.
export const tools = pgTable('tools', {
export const tools = pgTable(
'tools',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
@@ -110,16 +105,20 @@ export const tools = pgTable('tools', {
implementation: text('implementation'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
},
(table) => [
unique('uq_tools_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_tools_scope').on(table.scope),
index('idx_tools_user').on(table.userId),
]);
],
);
// ── Extensions ──
// Code-only, no markdown, no chat. Just implementation.
export const extensions = pgTable('extensions', {
export const extensions = pgTable(
'extensions',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
@@ -128,23 +127,29 @@ export const extensions = pgTable('extensions', {
implementation: text('implementation'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
},
(table) => [
unique('uq_extensions_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_extensions_scope').on(table.scope),
index('idx_extensions_user').on(table.userId),
]);
],
);
// ── Item Chats ──
// Chat history for tasks, skills, processes, resources, tools.
// Uses polymorphic reference (item_type + item_id) instead of per-table FKs.
export const itemChats = pgTable('item_chats', {
export const itemChats = pgTable(
'item_chats',
{
id: text('id').primaryKey(),
itemType: text('item_type').notNull(),
itemId: integer('item_id').notNull(),
messages: jsonb('messages').notNull().default([]),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
},
(table) => [
unique('uq_item_chats_type_item').on(table.itemType, table.itemId),
index('idx_item_chats_type_item').on(table.itemType, table.itemId),
]);
],
);
-5
View File
@@ -74,11 +74,6 @@ export type SkillInsert = typeof Schema.skills.$inferInsert;
export type ProcessSelect = typeof Schema.processes.$inferSelect;
export type ProcessInsert = typeof Schema.processes.$inferInsert;
// ── Resources ──
export type ResourceSelect = typeof Schema.resources.$inferSelect;
export type ResourceInsert = typeof Schema.resources.$inferInsert;
// ── Tools ──
export type ToolSelect = typeof Schema.tools.$inferSelect;
+36 -17
View File
@@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router';
import { resolve, dirname, join, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path';
import { getHomeDir, DATA_PATH } from '@@/data-path';
import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readSttConfig } from '@@/api/server-settings/stt';
@@ -11,7 +11,7 @@ import { getUserSettings } from 'officerdb';
async function getUserTtsVoice(userId: number): Promise<string | null> {
try {
const settings = await getUserSettings(userId) as { tts?: { voice?: string | null } };
const settings = (await getUserSettings(userId)) as { tts?: { voice?: string | null } };
return settings.tts?.voice ?? null;
} catch {}
return null;
@@ -282,11 +282,23 @@ router.get('/transcode', async (ctx) => {
const proc = Bun.spawn(
[
'ffmpeg', '-i', absPath,
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
'-y', tmpPath,
'ffmpeg',
'-i',
absPath,
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-crf',
'23',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
'-y',
tmpPath,
],
{ stdout: 'ignore', stderr: 'pipe' },
);
@@ -343,10 +355,10 @@ router.get('/transcode-audio', async (ctx) => {
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory');
const proc = Bun.spawn(
['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'],
{ stdout: 'pipe', stderr: 'ignore' },
);
const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'], {
stdout: 'pipe',
stderr: 'ignore',
});
return new Response(proc.stdout as ReadableStream, {
headers: {
@@ -799,14 +811,11 @@ router.post('/transcribe', async (ctx) => {
// Step 2: Check user's spoken languages to decide if translation is needed
let shouldTranslate = false;
const settingsFile = Bun.file(getUserSettingsFile(user.email));
if (await settingsFile.exists()) {
const settings = (await settingsFile.json()) as { languages?: { spoken?: string[] } };
const spokenLanguages = settings.languages?.spoken ?? [];
const settings = await getUserSettings(user.id);
const spokenLanguages = (settings.languages as { spoken?: string[] })?.spoken ?? [];
if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) {
shouldTranslate = true;
}
}
// Step 3: Full transcription
const transcribeForm = new FormData();
@@ -997,7 +1006,17 @@ router.post('/download-video', async (ctx) => {
const absPath = resolveUserPath(rootDir, path);
await mkdir(absPath, { recursive: true });
const ytdlp = Bun.which('yt-dlp') ?? `${process.env.HOME}/.local/bin/yt-dlp`;
const args = [ytdlp, '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--cookies-from-browser', 'brave', '-o', '%(title)s.%(ext)s'];
const args = [
ytdlp,
'--remote-components',
'ejs:github',
'--js-runtimes',
'node',
'--cookies-from-browser',
'brave',
'-o',
'%(title)s.%(ext)s',
];
if (audioOnly) args.push('-x', '--audio-format', 'mp3');
args.push(url);
+12 -137
View File
@@ -1,8 +1,7 @@
import { join } from 'path';
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { readdirSync, existsSync, mkdirSync } from 'node:fs';
import type { Subprocess } from 'bun';
import type { PiEvent, MessageCost } from './types';
import { readSearxngConfig } from '../server-settings/searxng';
import {
PI_CONFIG_DIR,
DATA_PATH,
@@ -13,13 +12,10 @@ import {
getUserExtensionsDir,
getGlobalToolsDir,
getUserToolsDir,
getNativeResourcesDir,
getGlobalResourcesDir,
toShellUsername,
} from '../../data-path';
import { getServerIntegration, getUserIntegration } from 'officerdb';
import { getServerIntegration, getUserIntegration, readConfigValue } from 'officerdb';
import { logger } from './logger';
import { parseFrontmatter } from '../skills/skills';
import { getRelayPort } from '../browser/relay';
import { registerUserToken } from '../browser/relay-auth';
@@ -75,125 +71,6 @@ function collectExtensionFlags(email: string): string[] {
return flags;
}
export function generateResourceSkill(outputDir: string): string | null {
const nativeDir = getNativeResourcesDir();
const globalDir = getGlobalResourcesDir();
// Collect all resource dirs (global overrides native)
const resourceDirs = new Map<string, string>();
for (const dir of [nativeDir, globalDir]) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(dir, entry.name, 'RESOURCE.md'))) {
resourceDirs.set(entry.name, dir);
}
}
}
if (resourceDirs.size === 0) return null;
const sections: string[] = [];
for (const [name, baseDir] of resourceDirs) {
const resourceMd = join(baseDir, name, 'RESOURCE.md');
let mdContent = '';
try {
mdContent = readFileSync(resourceMd, 'utf-8');
} catch {
continue;
}
const { frontmatter } = parseFrontmatter(mdContent);
// Merge native + global config
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try {
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
} catch {}
try {
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
} catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!;
const hasValues = Object.values(config).some((v) => v !== '');
const configLines = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => (/key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`));
sections.push(
[
`### ${frontmatter.name || name}`,
hasValues ? 'Status: **configured**' : 'Status: not configured',
...configLines,
].join('\n'),
);
}
const skillContent = [
'---',
'name: Available Resources',
'description: External services and APIs configured on this Officer instance',
'---',
'',
'These are external services available to you. Use their configured URLs directly via HTTP requests.',
'Do NOT try to install local alternatives (like tesseract, whisper, etc.) — use the configured HTTP APIs instead.',
'',
...sections,
].join('\n');
const skillDir = join(outputDir, '.generated', 'available-resources');
try {
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8');
return skillDir;
} catch {
logger.error(`Failed to write resource skill to ${skillDir}`);
return null;
}
}
function buildResourcesEnv(): string {
const nativeDir = getNativeResourcesDir();
const globalDir = getGlobalResourcesDir();
const resourceDirs = new Map<string, string>();
for (const dir of [nativeDir, globalDir]) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(dir, entry.name, 'RESOURCE.md')) || existsSync(join(dir, entry.name, 'config.json'))) {
resourceDirs.set(entry.name, dir);
}
}
}
const result: Record<string, Record<string, string>> = {};
for (const [name] of resourceDirs) {
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try {
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
} catch {}
try {
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
} catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!;
// Only include resources that have at least one non-empty value
if (Object.values(config).some((v) => v !== '')) {
result[name] = config;
}
}
return JSON.stringify(result);
}
async function getApifyToken(): Promise<string> {
try {
const integration = await getServerIntegration('apify');
@@ -248,13 +125,10 @@ export async function spawnPi(
onEvent: PiEventHandler,
options?: SpawnPiOptions,
): Promise<Subprocess> {
const searxng = await readSearxngConfig();
const searxngUrl = await readConfigValue('searxng-url', '');
const skillFlags = collectSkillFlags(email);
const extensionFlags = collectExtensionFlags(email);
const resourceSkillDir = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
const piArgs = [
...PI_CMD,
'--mode',
@@ -264,7 +138,6 @@ export async function spawnPi(
'--no-themes',
...skillFlags,
...extensionFlags,
...resourceSkillFlags,
];
if (model) piArgs.push('--model', model);
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
@@ -290,8 +163,7 @@ export async function spawnPi(
OFFICER_USER_ROOT: join(DATA_PATH, email),
PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'),
PI_TOOLS_DIRS: toolsDirs,
PI_SEARXNG_URL: searxng.url,
OFFICER_RESOURCES: buildResourcesEnv(),
PI_SEARXNG_URL: searxngUrl,
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
TERM: 'xterm-256color',
PATH: process.env.PATH ?? '',
@@ -465,12 +337,14 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
const toolName = (event.toolName as string) ?? 'unknown';
const args = (event.args as Record<string, unknown>) ?? {};
return [{
return [
{
type: 'tool:start',
toolCallId,
toolName,
toolInput: args,
}];
},
];
}
case 'tool_execution_end': {
@@ -489,12 +363,14 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false;
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
return [{
return [
{
type: 'tool:result',
toolCallId,
output,
isError,
}];
},
];
}
case 'agent_end': {
@@ -586,7 +462,6 @@ export async function buildHostToolEnv(userId: number, email: string, role?: str
HOME: homeDir,
OFFICER_USER_HOME: homeDir,
OFFICER_USER_ROOT: join(DATA_PATH, email),
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}),
...browserRelayEnv,
+1 -1
View File
@@ -33,7 +33,7 @@ scrapeRouter.post('/', async (ctx) => {
const { url, sessionId, provider } = ctx.get('body') as {
url: string;
sessionId?: string;
provider?: 'claude' | 'opencode' | 'pi-mono';
provider?: 'claude' | 'pi-mono';
};
if (!url) return ctx.json({ error: 'url is required' }, 400);
-4
View File
@@ -1,6 +1,5 @@
import { createRouter } from '../../create-router';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { readResourceConfig } from './resources';
type OcrConfig = {
url: string;
@@ -8,9 +7,6 @@ type OcrConfig = {
};
export async function readOcrConfig(): Promise<OcrConfig | undefined> {
const config = await readResourceConfig('optical-character-recognition');
if (config.url) return { url: config.url, model: config.model ?? '' };
// Fallback to legacy DB settings
const settings = await readServerSettings();
return settings.ocr as OcrConfig | undefined;
}
@@ -1,76 +0,0 @@
import { createRouter } from '../../create-router';
export const opencodeRouter = createRouter();
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
const getPaths = async () => {
try {
const proc = Bun.spawn(['which', '-a', 'opencode'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return { path: null, globalPath: null };
const paths = [...new Set(output.trim().split('\n'))];
const path = paths[0] ?? null;
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
return { path, globalPath };
} catch {
return { path: null, globalPath: null };
}
};
opencodeRouter.post('/auth/login', async (ctx) => {
try {
Bun.spawn(['opencode', 'auth', 'login'], { stdout: 'ignore', stderr: 'ignore' });
return ctx.json({ started: true });
} catch {
return ctx.json({ started: false, error: 'Failed to start login' }, 500);
}
});
opencodeRouter.get('/auth', async (ctx) => {
try {
const authPath = `${process.env.HOME}/.local/share/opencode/auth.json`;
const file = Bun.file(authPath);
if (!(await file.exists())) return ctx.json({ authenticated: false, providers: [] });
const auth = await file.json();
const providers = Object.keys(auth);
return ctx.json({ authenticated: providers.length > 0, providers });
} catch {
return ctx.json({ authenticated: false, providers: [] });
}
});
opencodeRouter.post('/install', async (ctx) => {
try {
const proc = Bun.spawn(['bash', '-c', 'curl -fsSL https://opencode.ai/install | bash'], {
stdout: 'pipe',
stderr: 'pipe',
});
await proc.exited;
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
}
const versionProc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(versionProc.stdout).text();
await versionProc.exited;
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
});
opencodeRouter.get('/version', async (ctx) => {
try {
const proc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null });
}
});
@@ -1,235 +0,0 @@
import { readdir, mkdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { createRouter } from '../../create-router';
import { getNativeResourcesDir, getGlobalResourcesDir } from '../../data-path';
import { parseFrontmatter } from '../skills/skills';
async function readResourceDirs(dir: string): Promise<Map<string, string>> {
const result = new Map<string, string>();
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const resourceFile = join(dir, entry.name, 'RESOURCE.md');
if (await Bun.file(resourceFile).exists()) {
result.set(entry.name, resourceFile);
}
}
} catch {
// directory doesn't exist yet
}
return result;
}
async function readConfigFile(dir: string): Promise<Record<string, string>> {
try {
return await Bun.file(join(dir, 'config.json')).json();
} catch {
return {};
}
}
function mergeConfig(native: Record<string, string>, global: Record<string, string>): Record<string, string> {
const merged: Record<string, string> = {};
for (const key of Object.keys(native)) {
merged[key] = global[key] ?? native[key]!;
}
for (const key of Object.keys(global)) {
if (!(key in merged)) merged[key] = global[key]!;
}
return merged;
}
function isPrivileged(role: string) {
return role === 'Super Admin';
}
export async function readResourceConfig(name: string): Promise<Record<string, string>> {
const nativeConfig = await readConfigFile(join(getNativeResourcesDir(), name));
const globalConfig = await readConfigFile(join(getGlobalResourcesDir(), name));
return mergeConfig(nativeConfig, globalConfig);
}
const CHECK_TIMEOUT_MS = 3_000;
export const resourcesRouter = createRouter();
resourcesRouter.get('/', async (ctx) => {
const nativeResources = await readResourceDirs(getNativeResourcesDir());
const globalResources = await readResourceDirs(getGlobalResourcesDir());
const merged = new Map(nativeResources);
for (const [name, path] of globalResources) merged.set(name, path);
const resources = await Promise.all(
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter } = parseFrontmatter(raw);
const scope = globalResources.has(dirName) && !nativeResources.has(dirName) ? 'global' as const : nativeResources.has(dirName) ? 'native' as const : 'global' as const;
const config = await readResourceConfig(dirName);
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, scope, config };
}),
);
return ctx.json(resources);
});
resourcesRouter.get('/:name', async (ctx) => {
const name = ctx.req.param('name');
const nativeResources = await readResourceDirs(getNativeResourcesDir());
const globalResources = await readResourceDirs(getGlobalResourcesDir());
const filePath = globalResources.get(name) ?? nativeResources.get(name);
if (!filePath) return ctx.text('Not found', 404);
const scope = globalResources.has(name) && !nativeResources.has(name) ? 'global' as const : 'native' as const;
const raw = await Bun.file(filePath).text();
const { frontmatter, body, rawYaml } = parseFrontmatter(raw);
const config = await readResourceConfig(name);
const globalConfigPath = join(getGlobalResourcesDir(), name, 'config.json');
const chatMeta = join(dirname(filePath), 'chat', 'meta.json');
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
const guidePath = join(getNativeResourcesDir(), 'GUIDE.md');
return ctx.json({
dirName: name,
name: frontmatter.name || name,
description: frontmatter.description,
scope,
body,
rawFrontmatter: rawYaml,
filePath,
config,
configPath: globalConfigPath,
chatSessionId,
guidePath,
});
});
resourcesRouter.patch('/:name/config', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const name = ctx.req.param('name');
const body = await ctx.req.json<Record<string, string | null>>();
const globalDir = join(getGlobalResourcesDir(), name);
await mkdir(globalDir, { recursive: true });
const existing = await readConfigFile(globalDir);
for (const [key, value] of Object.entries(body)) {
if (value === null) delete existing[key];
else existing[key] = value;
}
await Bun.write(join(globalDir, 'config.json'), JSON.stringify(existing, null, 2));
const merged = await readResourceConfig(name);
return ctx.json(merged);
});
resourcesRouter.post('/', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const { name } = await ctx.req.json<{ name: string }>();
if (!name?.trim()) return ctx.text('Name is required', 400);
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
if (!dirName) return ctx.text('Invalid name', 400);
const dir = join(getGlobalResourcesDir(), dirName);
const filePath = join(dir, 'RESOURCE.md');
if (await Bun.file(filePath).exists()) {
return ctx.text('Resource already exists', 409);
}
await mkdir(dir, { recursive: true });
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
await Bun.write(join(dir, 'config.json'), JSON.stringify({ url: '', api_key: '', username: '', password: '' }, null, 2));
return ctx.json({ name: name.trim(), dirName, filePath, scope: 'global' });
});
resourcesRouter.delete('/:name', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const name = ctx.req.param('name');
const nativeResources = await readResourceDirs(getNativeResourcesDir());
if (nativeResources.has(name)) return ctx.text('Cannot delete native resource', 400);
const dir = join(getGlobalResourcesDir(), name);
const filePath = join(dir, 'RESOURCE.md');
if (!(await Bun.file(filePath).exists())) return ctx.text('Not found', 404);
await rm(dir, { recursive: true });
return ctx.json({ ok: true });
});
resourcesRouter.get('/:name/chat', async (ctx) => {
const name = ctx.req.param('name');
const nativeResources = await readResourceDirs(getNativeResourcesDir());
const globalResources = await readResourceDirs(getGlobalResourcesDir());
const filePath = globalResources.get(name) ?? nativeResources.get(name);
if (!filePath) return ctx.text('Not found', 404);
const chatDir = join(getGlobalResourcesDir(), name, 'chat');
const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null);
const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []);
return ctx.json({ sessionId, messages });
});
resourcesRouter.put('/:name/chat', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const name = ctx.req.param('name');
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
const chatDir = join(getGlobalResourcesDir(), name, 'chat');
await mkdir(chatDir, { recursive: true });
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
return ctx.json({ ok: true });
});
resourcesRouter.delete('/:name/chat', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const name = ctx.req.param('name');
const chatDir = join(getGlobalResourcesDir(), name, 'chat');
await rm(chatDir, { recursive: true, force: true });
return ctx.json({ ok: true });
});
resourcesRouter.post('/:name/ping', async (ctx) => {
const name = ctx.req.param('name');
const body = await ctx.req.json<{ url?: string }>().catch((): { url?: string } => ({}));
const config = await readResourceConfig(name);
const url = body.url ?? config.url;
if (!url) return ctx.json({ error: 'No URL configured' }, 400);
try {
const start = performance.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
const latencyMs = Math.round(performance.now() - start);
return ctx.json({ reachable: true, latencyMs });
} catch {
return ctx.json({ reachable: false, latencyMs: null });
}
});
@@ -1,39 +0,0 @@
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { DATA_PATH } from '../../data-path';
export const searxngRouter = createRouter();
const SEARXNG_FILE = join(DATA_PATH, 'searxng.json');
const DEFAULT_URL = 'https://searxng.home.pastilhas.eu';
export type SearxngConfig = {
url: string;
};
export async function readSearxngConfig(): Promise<SearxngConfig> {
try {
const file = Bun.file(SEARXNG_FILE);
if (!(await file.exists())) return { url: DEFAULT_URL };
return (await file.json()) as SearxngConfig;
} catch {
return { url: DEFAULT_URL };
}
}
async function writeSearxngConfig(config: SearxngConfig) {
await Bun.write(SEARXNG_FILE, JSON.stringify(config, null, 2));
}
searxngRouter.get('/', async (ctx) => {
return ctx.json(await readSearxngConfig());
});
searxngRouter.put('/', async (ctx) => {
const { url } = await ctx.req.json<{ url: string }>();
if (!url?.trim()) return ctx.json({ error: 'URL is required' }, 400);
const config: SearxngConfig = { url: url.trim().replace(/\/+$/, '') };
await writeSearxngConfig(config);
return ctx.json(config);
});
@@ -3,28 +3,22 @@ import { readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { claudeCodeRouter } from './claude-code';
import { opencodeRouter } from './opencode';
import { piMonoRouter } from './pi-mono';
import { applicationsRouter } from './applications';
import { resourcesRouter } from './resources';
import { smtpRouter } from './smtp';
import { ttsRouter } from './tts';
import { sttRouter } from './stt';
import { ocrRouter } from './ocr';
import { searxngRouter } from './searxng';
export const serverSettingsRouter = createRouter();
serverSettingsRouter.route('/claude-code', claudeCodeRouter);
serverSettingsRouter.route('/opencode', opencodeRouter);
serverSettingsRouter.route('/pi-mono', piMonoRouter);
serverSettingsRouter.route('/applications', applicationsRouter);
serverSettingsRouter.route('/resources', resourcesRouter);
serverSettingsRouter.route('/smtp', smtpRouter);
serverSettingsRouter.route('/tts', ttsRouter);
serverSettingsRouter.route('/stt', sttRouter);
serverSettingsRouter.route('/ocr', ocrRouter);
serverSettingsRouter.route('/searxng', searxngRouter);
export { readServerSettings as readSettings };
-4
View File
@@ -1,15 +1,11 @@
import { createRouter } from '../../create-router';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { readResourceConfig } from './resources';
type SttConfig = {
url: string;
};
export async function readSttConfig(): Promise<SttConfig | undefined> {
const config = await readResourceConfig('speech-to-text');
if (config.url) return { url: config.url };
// Fallback to legacy DB settings
const settings = await readServerSettings();
return settings.stt as SttConfig | undefined;
}
-12
View File
@@ -1,6 +1,5 @@
import { createRouter } from '../../create-router';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { readResourceConfig } from './resources';
type TtsConfig = {
provider: 'openai' | 'elevenlabs';
@@ -16,20 +15,9 @@ function maskSecret(value: string | undefined): string | undefined {
}
export async function readTtsConfig(): Promise<TtsConfig | undefined> {
const config = await readResourceConfig('text-to-speech');
if (!config.url && !config.provider) {
// Fallback to legacy DB settings
const settings = await readServerSettings();
return settings.tts as TtsConfig | undefined;
}
return {
provider: (config.provider || 'openai') as 'openai' | 'elevenlabs',
url: config.url ?? '',
apiKey: config.api_key || undefined,
model: config.model ?? '',
voice: config.voice ?? '',
};
}
export const ttsRouter = createRouter();
+23 -139
View File
@@ -1,12 +1,9 @@
import { Hono } from 'hono';
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getClaudeDir, getSessionDir, getArchivedSessionDir, getOpencodeDir, getOpencodeSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path';
import { getClaudeDir, getSessionDir, getArchivedSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path';
import type { HonoVariables } from '@@/create-router';
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
const OPENCODE_BASE = `http://localhost:${OPENCODE_PORT}`;
export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
// --- List all sessions (merged from both providers) ---
@@ -14,13 +11,9 @@ export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
sessionsRouter.get('/sessions', async (ctx) => {
const { email } = ctx.get('user');
const [claudeSessions, opencodeSessions, piMonoSessions] = await Promise.all([
fetchClaudeSessions(email),
fetchOpencodeSessions(email),
fetchPiMonoSessions(email),
]);
const [claudeSessions, piMonoSessions] = await Promise.all([fetchClaudeSessions(email), fetchPiMonoSessions(email)]);
const merged = [...claudeSessions, ...opencodeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt);
const merged = [...claudeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt);
return ctx.json(merged);
});
@@ -34,17 +27,21 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
if (provider === 'claude') {
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
try {
return ctx.json(await file.json());
} catch {
return ctx.json([]);
}
if (provider === 'opencode') {
return ctx.json(await fetchOpencodeMessages(id));
}
if (provider === 'pi-mono') {
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
try {
return ctx.json(await file.json());
} catch {
return ctx.json([]);
}
}
return ctx.json({ error: 'invalid provider' }, 400);
@@ -55,7 +52,6 @@ sessionsRouter.put('/sessions/:provider/:id/messages', async (ctx) => {
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'opencode') return ctx.json({ error: 'opencode sessions are read-only' }, 400);
if (provider !== 'claude' && provider !== 'pi-mono') return ctx.json({ error: 'invalid provider' }, 400);
const messages = ctx.get('body');
@@ -79,28 +75,13 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
try {
meta = await metaFile.json();
} catch {
return ctx.json({ error: 'corrupted session' }, 500);
}
if (provider === 'opencode') {
const dir = getOpencodeSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
// Best-effort sync to OpenCode API
fetch(`${OPENCODE_BASE}/session/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: meta.title }),
}).catch(() => {});
return ctx.json({ ok: true });
}
@@ -109,7 +90,11 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
try {
meta = await metaFile.json();
} catch {
return ctx.json({ error: 'corrupted session' }, 500);
}
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
@@ -135,18 +120,6 @@ sessionsRouter.delete('/sessions/:provider/:id', async (ctx) => {
return ctx.json({ ok: true });
}
if (provider === 'opencode') {
const dir = getOpencodeSessionDir(email, id);
try {
await rm(dir, { recursive: true });
} catch {
// dir may not exist
}
// Best-effort sync to OpenCode API
fetch(`${OPENCODE_BASE}/session/${id}`, { method: 'DELETE' }).catch(() => {});
return ctx.json({ ok: true });
}
if (provider === 'pi-mono') {
const dir = getPiMonoSessionDir(email, id);
try {
@@ -167,7 +140,7 @@ sessionsRouter.post('/sessions/:provider/:id/archive', async (ctx) => {
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'opencode' || provider === 'pi-mono') return ctx.json({ error: `${provider} sessions cannot be archived` }, 400);
if (provider === 'pi-mono') return ctx.json({ error: 'pi-mono sessions cannot be archived' }, 400);
if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400);
const src = getSessionDir(email, id);
@@ -183,7 +156,7 @@ type SessionMeta = {
id: string;
title: string;
createdAt: number;
provider: 'claude' | 'opencode' | 'pi-mono';
provider: 'claude' | 'pi-mono';
model?: string | null;
};
@@ -211,28 +184,6 @@ async function fetchClaudeSessions(email: string): Promise<SessionMeta[]> {
}
}
async function fetchOpencodeSessions(email: string): Promise<SessionMeta[]> {
const dir = getOpencodeDir(email);
try {
const entries = await readdir(dir);
const sessions = await Promise.all(
entries.map(async (id) => {
try {
const metaFile = Bun.file(join(dir, id, 'meta.json'));
if (!(await metaFile.exists())) return null;
const meta = await metaFile.json();
return { ...meta, provider: 'opencode' as const };
} catch {
return null;
}
}),
);
return sessions.filter((s): s is SessionMeta => s !== null);
} catch {
return [];
}
}
async function fetchPiMonoSessions(email: string): Promise<SessionMeta[]> {
const dir = getPiMonoDir(email);
try {
@@ -254,70 +205,3 @@ async function fetchPiMonoSessions(email: string): Promise<SessionMeta[]> {
return [];
}
}
async function fetchOpencodeMessages(id: string) {
try {
const res = await fetch(`${OPENCODE_BASE}/session/${id}/message`);
if (!res.ok) return [];
const data = (await res.json()) as any[];
const messages = Array.isArray(data) ? data : Object.values(data);
const chatMessages: any[] = [];
for (const msg of messages) {
const role = msg.info?.role ?? msg.role;
if (role === 'user') {
const text = Array.isArray(msg.parts)
? msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text ?? p.content ?? '')
.join('')
: typeof msg.content === 'string'
? msg.content
: '';
if (text) chatMessages.push({ role: 'user', text });
} else if (role === 'assistant') {
if (Array.isArray(msg.parts)) {
for (const part of msg.parts) {
if (part.type === 'text' && (part.text || part.content)) {
chatMessages.push({ role: 'assistant', text: part.text ?? part.content ?? '' });
} else if (part.type === 'tool') {
chatMessages.push({
role: 'tool',
toolName: part.tool ?? 'unknown',
toolInput: part.state?.input ?? {},
toolUseId: part.callID ?? part.id ?? '',
output:
part.state?.output != null
? typeof part.state.output === 'string'
? part.state.output
: JSON.stringify(part.state.output)
: undefined,
isError: part.state?.status === 'error',
});
} else if (part.type === 'tool-invocation') {
const inv = part.toolInvocation ?? part;
chatMessages.push({
role: 'tool',
toolName: inv.toolName ?? 'unknown',
toolInput: inv.args ?? {},
toolUseId: inv.toolCallId ?? part.id ?? '',
output:
inv.result != null
? typeof inv.result === 'string'
? inv.result
: JSON.stringify(inv.result)
: undefined,
isError: !!part.isError,
});
}
}
}
}
}
return chatMessages;
} catch {
return [];
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ uploadRouter.post('/', async (ctx) => {
const file = body.file as File | null;
const sessionId = (body.sessionId as string) || null;
const provider = (body.provider as 'claude' | 'opencode' | 'pi-mono') || null;
const provider = (body.provider as 'claude' | 'pi-mono') || null;
if (!file || !(file instanceof File)) {
return ctx.json({ error: 'file is required' }, 400);
-7
View File
@@ -5,9 +5,6 @@ import { DATA_PATH } from './data-path';
import { syncSeedSkills } from './sync-skills';
import { syncSeedTools } from './sync-tools';
import { syncSeedExtensions } from './sync-extensions';
import { syncSeedResources } from './sync-resources';
import { migrateSettingsToResources } from './migrate-resources';
import { generateResourceSkill } from './api/pi/pi-bridge';
// Queue is now owned by the sidecar process
import { startDiscordBotIfConfigured } from './channels/discord/bot';
import { startTelegramBotIfConfigured } from './channels/telegram/bot';
@@ -74,10 +71,6 @@ async function installPi(): Promise<boolean> {
syncSeedSkills();
syncSeedTools();
syncSeedExtensions();
syncSeedResources();
await migrateSettingsToResources();
generateResourceSkill(DATA_PATH);
// Queue is initialized by the sidecar process
await startDiscordBotIfConfigured().catch((err) => {
+1 -1
View File
@@ -9,7 +9,7 @@ import { getUserSettings } from 'officerdb';
import { logger } from '@@/api/pi/logger';
import { sendClaudeCode, clearClaudeCodeSession } from './send-claude-code';
const DEFAULT_MODEL = 'opencode/big-pickle';
const DEFAULT_MODEL = 'anthropic/claude-sonnet-4-20250514';
const IDLE_TIMEOUT_MS = 60 * 60 * 1000;
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
+8 -16
View File
@@ -16,11 +16,6 @@ export const getClaudeDir = (email: string) => join(DATA_PATH, email, 'chat_sess
export const getSessionDir = (email: string, sessionId: string) =>
join(DATA_PATH, email, 'chat_sessions', 'claude', sessionId);
export const getOpencodeDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'opencode');
export const getOpencodeSessionDir = (email: string, sessionId: string) =>
join(DATA_PATH, email, 'chat_sessions', 'opencode', sessionId);
export const getPiMonoDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'pi-mono');
export const getPiMonoSessionDir = (email: string, sessionId: string) =>
@@ -38,8 +33,6 @@ export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'hom
export const getUserSettingsDir = (email: string) => join(DATA_PATH, email, 'settings');
export const getUserSettingsFile = (email: string) => join(DATA_PATH, email, 'settings', 'settings.json');
export const getUserStateDir = (email: string) => join(DATA_PATH, email, 'state');
export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state', 'state.json');
@@ -80,20 +73,13 @@ export const getGlobalProcessesDir = () => join(DATA_PATH, 'processes');
export const getUserProcessesDir = (email: string) => join(DATA_PATH, email, 'processes');
export const getNativeResourcesDir = () => join(SEED_PATH, 'resources');
export const getGlobalResourcesDir = () => join(DATA_PATH, 'resources');
export const getResourcesDir = () => join(DATA_PATH, 'resources');
export const getTaskLogsDir = (email: string) => join(DATA_PATH, email, 'logs', 'tasks');
export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'tmp_attachments');
export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string) =>
export const getAttachmentsDir = (email: string, provider: 'claude' | 'pi-mono', sessionId: string) =>
join(DATA_PATH, email, 'chat_sessions', provider, sessionId, 'attachments');
export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails');
export const getMaildirPath = (email: string) => join(DATA_PATH, email, 'Gmail', 'Maildir');
@@ -102,7 +88,13 @@ export const getMaildirPath = (email: string) => join(DATA_PATH, email, 'Gmail',
export const toShellUsername = (username: string, email: string): string => {
const raw = username || email.split('@')[0]!;
// Replace invalid chars, lowercase, truncate to 32 chars
return raw.replace(/@.*$/, '').replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase().slice(0, 32) || 'officer';
return (
raw
.replace(/@.*$/, '')
.replace(/[^a-zA-Z0-9._-]/g, '_')
.toLowerCase()
.slice(0, 32) || 'officer'
);
};
export const getUserAppsDir = (email: string) => join(DATA_PATH, email, 'apps');
@@ -7,7 +7,6 @@ import {
getUserSkillsDir,
getGlobalTasksDir,
getUserTasksDir,
getGlobalResourcesDir,
getHomeDir,
DATA_PATH,
} from '@@/data-path';
@@ -61,7 +60,6 @@ export function generateContainerContext(email: string): string {
const tools = dedup([...scanDir(getGlobalToolsDir(), 'TOOL.md'), ...scanDir(getUserToolsDir(email), 'TOOL.md')]);
const skills = dedup([...scanDir(getGlobalSkillsDir(), 'SKILL.md'), ...scanDir(getUserSkillsDir(email), 'SKILL.md')]);
const tasks = dedup([...scanDir(getGlobalTasksDir(), 'TASK.md'), ...scanDir(getUserTasksDir(email), 'TASK.md')]);
const resources = scanDir(getGlobalResourcesDir(), 'RESOURCE.md');
const globalToolsDir = getGlobalToolsDir();
const userToolsDir = getUserToolsDir(email);
@@ -100,11 +98,6 @@ ${formatList(skills)}
Tasks are predefined instruction sets the AI agent can execute.
${formatList(tasks)}
## Configured Resources
Resources are external service integrations (TTS, STT, OCR, etc.) configured in Settings.
${formatList(resources)}
## Creating New Tools
Create a directory in \`${userToolsDir}/<tool-name>/\` with two files:
@@ -139,7 +132,6 @@ export async function execute(_toolCallId: string, params: Record<string, unknow
| Variable | Description |
|----------|-------------|
| \`OFFICER_EMAIL_DB\` | Path to email SQLite database |
| \`OFFICER_RESOURCES\` | JSON with configured resource integrations |
| \`PI_TOOLS_DIRS\` | Tool discovery paths (colon-separated) |
| \`PI_SEARXNG_URL\` | Search engine URL |
`;
-53
View File
@@ -1,53 +0,0 @@
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH, SEED_PATH } from './data-path';
import { readServerSettings } from 'officerdb';
const SETTINGS_TO_RESOURCE: Record<string, string> = {
stt: 'speech-to-text',
tts: 'text-to-speech',
ocr: 'optical-character-recognition',
};
export async function migrateSettingsToResources(): Promise<void> {
let settings: Record<string, Record<string, string>>;
try {
settings = (await readServerSettings()) as Record<string, Record<string, string>>;
} catch {
return;
}
for (const [settingsKey, resourceName] of Object.entries(SETTINGS_TO_RESOURCE)) {
const raw = settings[settingsKey];
if (!raw || typeof raw !== 'object') continue;
const globalDir = join(DATA_PATH, 'resources', resourceName);
const globalConfigPath = join(globalDir, 'config.json');
if (existsSync(globalConfigPath)) continue;
// Read seed config for schema (all keys)
let seedConfig: Record<string, string> = {};
try {
seedConfig = JSON.parse(readFileSync(join(SEED_PATH, 'resources', resourceName, 'config.json'), 'utf-8'));
} catch {
// no seed config
}
// Merge settings values into seed schema
const merged: Record<string, string> = {};
for (const key of Object.keys(seedConfig)) {
merged[key] = seedConfig[key]!;
}
for (const [key, value] of Object.entries(raw)) {
if (typeof value === 'string') {
const mapped = key === 'apiKey' ? 'api_key' : key;
merged[mapped] = value;
}
}
mkdirSync(globalDir, { recursive: true });
writeFileSync(globalConfigPath, JSON.stringify(merged, null, 2), 'utf-8');
console.log(`[resources] Migrated ${settingsKey} settings to ${resourceName}/config.json`);
}
}
+45 -57
View File
@@ -1,5 +1,5 @@
import { join } from 'node:path';
import { readdirSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import { readdirSync, existsSync, mkdirSync } from 'node:fs';
import type { Subprocess } from 'bun';
import type { PiEvent, MessageCost } from '../api/pi/types';
import type { PiSpawnParams, PiSessionInfo } from './protocol';
@@ -7,8 +7,6 @@ import { isPidAlive } from './state';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const PI_CONFIG_DIR = join(require('node:os').homedir(), '.pi', 'agent');
const SEED_PATH = join(import.meta.dir, '../../../seed');
const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
const getHomeDirForRole = (email: string, role: string | null): string =>
role === 'Super Admin' && process.env.HOME_DIR ? process.env.HOME_DIR : getHomeDir(email);
@@ -18,12 +16,15 @@ const getGlobalExtensionsDir = () => join(DATA_PATH, 'extensions');
const getUserExtensionsDir = (email: string) => join(DATA_PATH, email, 'extensions');
const getGlobalToolsDir = () => join(DATA_PATH, 'tools');
const getUserToolsDir = (email: string) => join(DATA_PATH, email, 'tools');
const getNativeResourcesDir = () => join(SEED_PATH, 'resources');
const getGlobalResourcesDir = () => join(DATA_PATH, 'resources');
const toShellUsername = (username: string, email: string): string => {
const raw = username || email.split('@')[0]!;
return raw.replace(/@.*$/, '').replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase().slice(0, 32) || 'officer';
return (
raw
.replace(/@.*$/, '')
.replace(/[^a-zA-Z0-9._-]/g, '_')
.toLowerCase()
.slice(0, 32) || 'officer'
);
};
// Resolve pi as [node, cli.js]
@@ -85,35 +86,6 @@ function collectExtensionFlags(email: string): string[] {
return flags;
}
function buildResourcesEnv(): string {
const nativeDir = getNativeResourcesDir();
const globalDir = getGlobalResourcesDir();
const resourceDirs = new Map<string, string>();
for (const dir of [nativeDir, globalDir]) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(dir, entry.name, 'RESOURCE.md')) || existsSync(join(dir, entry.name, 'config.json'))) {
resourceDirs.set(entry.name, dir);
}
}
}
const result: Record<string, Record<string, string>> = {};
for (const [name] of resourceDirs) {
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!;
if (Object.values(config).some((v) => v !== '')) {
result[name] = config;
}
}
return JSON.stringify(result);
}
async function resolveApiKeyForModel(model: string): Promise<string | null> {
const provider = model.split('/')[0];
if (!provider) return null;
@@ -134,7 +106,9 @@ function parseErrorMessage(raw: string): string {
const parsed = JSON.parse(raw.replace(/^\d+\s*/, ''));
const inner = parsed?.error;
if (inner?.message) return inner.message;
} catch { /* not JSON */ }
} catch {
/* not JSON */
}
return raw;
}
@@ -181,12 +155,14 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
}
case 'tool_execution_start':
return [{
return [
{
type: 'tool:start',
toolCallId: (event.toolCallId as string) ?? '',
toolName: (event.toolName as string) ?? 'unknown',
toolInput: (event.args as Record<string, unknown>) ?? {},
}];
},
];
case 'tool_execution_end': {
const toolCallId = (event.toolCallId as string) ?? '';
@@ -195,7 +171,11 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
if (typeof result === 'object' && result !== null) {
resultObj = result as Record<string, unknown>;
} else if (typeof result === 'string') {
try { resultObj = JSON.parse(result); } catch { /* not JSON */ }
try {
resultObj = JSON.parse(result);
} catch {
/* not JSON */
}
}
const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false;
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
@@ -260,18 +240,15 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
const skillFlags = collectSkillFlags(email);
const extensionFlags = collectExtensionFlags(email);
// Generate resource skill
const { generateResourceSkill } = await import('../api/pi/pi-bridge');
const resourceSkillDir = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
const piArgs = [
...PI_CMD,
'--mode', 'rpc',
'--no-skills', '--no-prompt-templates', '--no-themes',
'--mode',
'rpc',
'--no-skills',
'--no-prompt-templates',
'--no-themes',
...skillFlags,
...extensionFlags,
...resourceSkillFlags,
];
if (model) piArgs.push('--model', model);
if (sessionFile) piArgs.push('--session', sessionFile);
@@ -294,7 +271,6 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
OFFICER_USER_ROOT: join(DATA_PATH, email),
PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'),
PI_TOOLS_DIRS: toolsDirs,
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
TERM: 'xterm-256color',
PATH: process.env.PATH ?? '',
@@ -302,10 +278,12 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
const proc = isServiceUser
? Bun.spawn(piArgs, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...env } })
: Bun.spawn(
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs],
{ cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
);
: Bun.spawn(['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs], {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
const session: PiSession = { sessionId, email, userId, model, cwd, proc, onEvent };
sessions.set(sessionId, session);
@@ -340,10 +318,14 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
}
onEvent(piEvent);
}
} catch { /* skip */ }
} catch {
/* skip */
}
}
} catch { /* process ended */ }
}
} catch {
/* process ended */
}
})();
// Stderr → log
@@ -358,7 +340,9 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
const text = stderrDecoder.decode(value, { stream: true });
if (text.trim()) console.log(`[sidecar:pi:stderr] ${text.trim()}`);
}
} catch { /* process ended */ }
} catch {
/* process ended */
}
})();
// Handle exit
@@ -394,7 +378,11 @@ export function setThinkingLevel(sessionId: string, level: string): boolean {
export function killPiSession(sessionId: string): boolean {
const session = sessions.get(sessionId);
if (!session) return false;
try { session.proc.kill(); } catch { /* already dead */ }
try {
session.proc.kill();
} catch {
/* already dead */
}
sessions.delete(sessionId);
return true;
}
-9
View File
@@ -1,9 +0,0 @@
import { mkdirSync } from 'node:fs';
import { DATA_PATH } from './data-path';
import { join } from 'node:path';
export function syncSeedResources(): void {
// Native resources are read directly from seed/ at runtime.
// Only ensure the global resources directory exists.
mkdirSync(join(DATA_PATH, 'resources'), { recursive: true });
}
@@ -6,7 +6,6 @@ import type { ChatMessage } from '../types';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode Zen',
google: 'Google',
groq: 'Groq',
mistral: 'Mistral',
@@ -106,7 +105,11 @@ export function ModelSelector({
)}
<div className="flex items-center gap-2">
{availableModels.find((m) => m.id === displayModel)?.reasoning && (
<ThinkingToggle enabled={thinkingLevel === 'high'} onToggle={() => onThinkingChange(thinkingLevel === 'high' ? 'off' : 'high')} disabled={isGenerating} />
<ThinkingToggle
enabled={thinkingLevel === 'high'}
onToggle={() => onThinkingChange(thinkingLevel === 'high' ? 'off' : 'high')}
disabled={isGenerating}
/>
)}
<div className="text-xs text-duck-dark/50">
{providerModels.length > 0 ? (
@@ -150,9 +153,7 @@ const ThinkingToggle = ({ enabled, onToggle, disabled }: ThinkingToggleProps) =>
disabled={disabled}
title={enabled ? 'Thinking enabled (click to disable)' : 'Thinking disabled (click to enable)'}
className={`rounded-md px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
enabled
? 'bg-duck-teal/15 text-duck-teal'
: 'text-duck-dark/40 hover:text-duck-dark/60'
enabled ? 'bg-duck-teal/15 text-duck-teal' : 'text-duck-dark/40 hover:text-duck-dark/60'
}`}
>
{enabled ? 'think' : 'no think'}
@@ -102,7 +102,7 @@ export type LegacySessionEntry = {
id: string;
title: string;
createdAt: number;
provider: 'claude' | 'opencode' | 'pi-mono';
provider: 'claude' | 'pi-mono';
model?: string | null;
};
-2
View File
@@ -9,8 +9,6 @@ export { useRecentModels } from './useRecentModels';
export { usePlans } from './usePlans';
export { useLandingPage } from './useLandingPage';
export { useServerSettings } from './useServerSettings';
export { useResources } from './useResources';
export type { ResourceSummary, ResourceDetail, PingResult } from './useResources';
export { useChatSessions } from './useChatSessions';
export type { UseChatSessionsType } from './useChatSessions';
export { useChatGroups } from './useChatGroups';
-61
View File
@@ -1,61 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type ResourceSummary = {
dirName: string;
name: string;
description: string;
scope: 'native' | 'global';
config: Record<string, string>;
};
export type ResourceDetail = ResourceSummary & {
body: string;
rawFrontmatter: string;
filePath: string;
configPath: string;
chatSessionId: string | null;
guidePath: string;
};
export type PingResult = {
reachable: boolean;
latencyMs: number | null;
};
const RESOURCES_KEY = ['RESOURCES'];
export const useResources = () => {
const client = useClient();
const queryClient = useQueryClient();
const { data: resources, isLoading } = useQuery({
queryKey: RESOURCES_KEY,
queryFn: () => client.get<ResourceSummary[]>('/server-settings/resources'),
});
const getDetail = (name: string) =>
client.get<ResourceDetail>(`/server-settings/resources/${name}`);
const saveConfig = async (name: string, config: Record<string, string | null>) => {
await client.patch(`/server-settings/resources/${name}/config`, config);
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
};
const createResource = async (name: string) => {
const result = await client.post<{ name: string; dirName: string }>('/server-settings/resources', { name });
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
return result;
};
const deleteResource = async (name: string) => {
await client.delete(`/server-settings/resources/${name}`);
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
};
const pingResource = async (name: string, url?: string) => {
return client.post<PingResult>(`/server-settings/resources/${name}/ping`, { url });
};
return { resources, isLoading, getDetail, saveConfig, createResource, deleteResource, pingResource };
};
@@ -4,7 +4,6 @@ import { useClient } from 'hooks/useClient';
type AIHarnesses = {
claudeCode: boolean;
opencode: boolean;
piMono: boolean;
};