wip: remove opencode, searxng, resources; fix user settings read
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,111 +86,51 @@ 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...'
|
||||
) : claudeVersion?.version ? (
|
||||
<>
|
||||
<div>{claudeVersion.version}</div>
|
||||
<div>{claudeVersion.path}</div>
|
||||
{claudeAuth && (
|
||||
<div className={`mt-1 ${claudeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{claudeAuth.authenticated ? (
|
||||
`Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})`
|
||||
) : (
|
||||
<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/claude-code/auth/login')}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!claudeVersion.globalPath && claudeVersion.path && (
|
||||
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
<span className="text-sm font-medium text-duck-dark">Claude Code</span>
|
||||
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
|
||||
{claudeLoading ? (
|
||||
'Checking version...'
|
||||
) : claudeVersion?.version ? (
|
||||
<>
|
||||
<div>{claudeVersion.version}</div>
|
||||
<div>{claudeVersion.path}</div>
|
||||
{claudeAuth && (
|
||||
<div className={`mt-1 ${claudeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{claudeAuth.authenticated ? (
|
||||
`Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})`
|
||||
) : (
|
||||
<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/claude-code/auth/login')}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!claudeVersion.globalPath && claudeVersion.path && (
|
||||
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={installClaude}
|
||||
disabled={installing}
|
||||
>
|
||||
{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 "{detail.name}"? 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,143 +8,148 @@ import { users } from './auth';
|
||||
// ── Tasks ──
|
||||
// Complex frontmatter: inputs, outputs, dependencies, triggers, config, tags, tools, skills
|
||||
|
||||
export const tasks = pgTable('tasks', {
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
body: text('body'),
|
||||
version: integer('version').notNull().default(1),
|
||||
tags: jsonb('tags').$type<string[]>(),
|
||||
tools: jsonb('tools').$type<string[]>(),
|
||||
skills: jsonb('skills').$type<string[]>(),
|
||||
inputs: jsonb('inputs'),
|
||||
outputs: jsonb('outputs'),
|
||||
dependencies: jsonb('dependencies'),
|
||||
config: jsonb('config'),
|
||||
trigger: jsonb('trigger'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (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),
|
||||
]);
|
||||
export const tasks = pgTable(
|
||||
'tasks',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
body: text('body'),
|
||||
version: integer('version').notNull().default(1),
|
||||
tags: jsonb('tags').$type<string[]>(),
|
||||
tools: jsonb('tools').$type<string[]>(),
|
||||
skills: jsonb('skills').$type<string[]>(),
|
||||
inputs: jsonb('inputs'),
|
||||
outputs: jsonb('outputs'),
|
||||
dependencies: jsonb('dependencies'),
|
||||
config: jsonb('config'),
|
||||
trigger: jsonb('trigger'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(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', {
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
body: text('body'),
|
||||
version: integer('version').notNull().default(1),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (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),
|
||||
]);
|
||||
export const skills = pgTable(
|
||||
'skills',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
body: text('body'),
|
||||
version: integer('version').notNull().default(1),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(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', {
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
body: text('body'),
|
||||
version: integer('version').notNull().default(1),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (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),
|
||||
]);
|
||||
export const processes = pgTable(
|
||||
'processes',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
body: text('body'),
|
||||
version: integer('version').notNull().default(1),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(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),
|
||||
],
|
||||
);
|
||||
|
||||
// ── Tools ──
|
||||
// Has implementation code, language, structured input params, label.
|
||||
|
||||
export const tools = pgTable('tools', {
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
label: text('label'),
|
||||
description: text('description'),
|
||||
body: text('body'),
|
||||
version: integer('version').notNull().default(1),
|
||||
language: text('language'),
|
||||
inputs: jsonb('inputs'),
|
||||
implementation: text('implementation'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (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),
|
||||
]);
|
||||
export const tools = pgTable(
|
||||
'tools',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
label: text('label'),
|
||||
description: text('description'),
|
||||
body: text('body'),
|
||||
version: integer('version').notNull().default(1),
|
||||
language: text('language'),
|
||||
inputs: jsonb('inputs'),
|
||||
implementation: text('implementation'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(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', {
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
implementation: text('implementation'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (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),
|
||||
]);
|
||||
export const extensions = pgTable(
|
||||
'extensions',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
dirName: text('dir_name').notNull(),
|
||||
name: text('name').notNull(),
|
||||
implementation: text('implementation'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(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', {
|
||||
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) => [
|
||||
unique('uq_item_chats_type_item').on(table.itemType, table.itemId),
|
||||
index('idx_item_chats_type_item').on(table.itemType, table.itemId),
|
||||
]);
|
||||
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) => [
|
||||
unique('uq_item_chats_type_item').on(table.itemType, table.itemId),
|
||||
index('idx_item_chats_type_item').on(table.itemType, table.itemId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,13 +811,10 @@ 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 ?? [];
|
||||
if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) {
|
||||
shouldTranslate = true;
|
||||
}
|
||||
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
|
||||
@@ -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);
|
||||
|
||||
|
||||
+20
-145
@@ -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 [{
|
||||
type: 'tool:start',
|
||||
toolCallId,
|
||||
toolName,
|
||||
toolInput: args,
|
||||
}];
|
||||
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 [{
|
||||
type: 'tool:result',
|
||||
toolCallId,
|
||||
output,
|
||||
isError,
|
||||
}];
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { readServerSettings, writeServerSettings } from 'officerdb';
|
||||
import { readResourceConfig } from './resources';
|
||||
|
||||
type TtsConfig = {
|
||||
provider: 'openai' | 'elevenlabs';
|
||||
@@ -16,19 +15,8 @@ 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 ?? '',
|
||||
};
|
||||
const settings = await readServerSettings();
|
||||
return settings.tts as TtsConfig | undefined;
|
||||
}
|
||||
|
||||
export const ttsRouter = createRouter();
|
||||
|
||||
@@ -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([]); }
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
return ctx.json(await fetchOpencodeMessages(id));
|
||||
try {
|
||||
return ctx.json(await file.json());
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
}
|
||||
|
||||
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,37 +75,26 @@ 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 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (provider === 'pi-mono') {
|
||||
const dir = getPiMonoSessionDir(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); }
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 |
|
||||
`;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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 [{
|
||||
type: 'tool:start',
|
||||
toolCallId: (event.toolCallId as string) ?? '',
|
||||
toolName: (event.toolName as string) ?? 'unknown',
|
||||
toolInput: (event.args as Record<string, unknown>) ?? {},
|
||||
}];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -50,7 +49,7 @@ export function ModelSelector({
|
||||
|
||||
// Determine which model to display: selectedModel takes precedence, then model (from server), then fallback
|
||||
const displayModel = selectedModel || model;
|
||||
|
||||
|
||||
const activeProvider = availableModels.find((m) => m.id === displayModel)?.provider ?? providers[0];
|
||||
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
|
||||
const fallbackModelId = providerModels[0]?.id ?? null;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user