From 05f0d0e8f7d92520c8ee7c75e12695db7f7bb143 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 24 Feb 2026 16:44:22 +0000 Subject: [PATCH] resources --- seed/resources/GUIDE.md | 51 ++ .../SERVICE_optical_character_recognition.md | 6 + seed/resources/SERVICE_speech_to_text.md | 6 + seed/resources/SERVICE_text_to_speech.md | 6 + .../optical-character-recognition/RESOURCE.md | 5 + .../optical-character-recognition/config.json | 7 + seed/resources/speech-to-text/RESOURCE.md | 5 + seed/resources/speech-to-text/config.json | 6 + seed/resources/text-to-speech/RESOURCE.md | 5 + seed/resources/text-to-speech/config.json | 9 + seed/tools/ocr/TOOL.md | 27 + seed/tools/ocr/index.ts | 112 +++ .../IntegrationsSettings/GoogleAccount.tsx | 6 +- .../ResourceSettings/ResourceSidebar.tsx | 201 +++--- .../Settings/ResourceSettings/Resources.tsx | 664 ++++++++---------- .../Settings/ResourceSettings/index.tsx | 153 +--- .../ResourceSettings/run-command-channel.ts | 12 - src/servers/api/integrations/integrations.ts | 4 + src/servers/api/pi/pi-bridge.ts | 122 +++- src/servers/api/server-settings/ocr.ts | 4 + src/servers/api/server-settings/resources.ts | 497 ++++++------- src/servers/api/server-settings/stt.ts | 4 + src/servers/api/server-settings/tts.ts | 16 +- .../api/terminal/Dockerfile.terminal-sidecar | 15 +- src/servers/api/terminal/websocket.ts | 3 +- src/servers/bootstrap.ts | 4 + src/servers/data-path.ts | 4 + src/servers/migrate-resources.ts | 53 ++ src/servers/sync-processes.ts | 32 + src/servers/sync-resources.ts | 9 + src/servers/sync-tasks.ts | 32 + .../components/TaskRunnerDialog.tsx | 3 +- .../components/TaskRunnerModal.tsx | 161 ++++- .../FileBrowserApp/useFileBrowserApp.ts | 1 + .../FileViewer/renderers/CodeRenderer.tsx | 12 +- .../FileViewer/renderers/MarkdownRenderer.tsx | 87 +-- .../apps/FileViewer/renderers/highlight.ts | 25 + src/workspaces/state/src/index.ts | 4 +- src/workspaces/state/src/useResources.ts | 69 +- 39 files changed, 1385 insertions(+), 1057 deletions(-) create mode 100644 seed/resources/GUIDE.md create mode 100644 seed/resources/SERVICE_optical_character_recognition.md create mode 100644 seed/resources/SERVICE_speech_to_text.md create mode 100644 seed/resources/SERVICE_text_to_speech.md create mode 100644 seed/resources/optical-character-recognition/RESOURCE.md create mode 100644 seed/resources/optical-character-recognition/config.json create mode 100644 seed/resources/speech-to-text/RESOURCE.md create mode 100644 seed/resources/speech-to-text/config.json create mode 100644 seed/resources/text-to-speech/RESOURCE.md create mode 100644 seed/resources/text-to-speech/config.json create mode 100644 seed/tools/ocr/TOOL.md create mode 100644 seed/tools/ocr/index.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/run-command-channel.ts create mode 100644 src/servers/migrate-resources.ts create mode 100644 src/servers/sync-processes.ts create mode 100644 src/servers/sync-resources.ts create mode 100644 src/servers/sync-tasks.ts create mode 100644 src/workspaces/officerdev/src/apps/FileViewer/renderers/highlight.ts diff --git a/seed/resources/GUIDE.md b/seed/resources/GUIDE.md new file mode 100644 index 00000000..44a4a355 --- /dev/null +++ b/seed/resources/GUIDE.md @@ -0,0 +1,51 @@ +# Resource Configuration Guide + +You are an AI assistant helping users configure **resources** — external services and APIs that Officer connects to. + +## What is a Resource? + +A resource represents an external service (e.g., a TTS server, an STT API, an OCR endpoint). Each resource has: +- A `RESOURCE.md` file with frontmatter metadata (name, description) and a markdown body +- A `config.json` file with flat key-value connection settings (all strings) + +## config.json Format + +The config is a flat JSON object where every value is a string. Empty string means "not set". + +Base fields (always present): +- `url` — the service endpoint URL +- `api_key` — API key or token for authentication +- `username` — username for basic auth +- `password` — password for basic auth + +Additional fields vary per resource (e.g., `provider`, `model`, `voice`). + +Example: +```json +{ + "url": "http://localhost:8000", + "api_key": "", + "username": "", + "password": "", + "provider": "openai", + "model": "tts-1", + "voice": "alloy" +} +``` + +## How to Help the User + +1. **Ask what service they're connecting to** — provider name, URL, auth method +2. **Fill in the config.json** — write the file with the values they provide +3. **Explain each field** — tell them what each key does and what format it expects +4. **For new resources**, also write the `RESOURCE.md` with an appropriate name and description in the frontmatter + +## RESOURCE.md Format + +```markdown +--- +name: Human Readable Name +description: One-line description of what this resource does +--- +Optional longer markdown body with usage notes, compatible providers, etc. +``` diff --git a/seed/resources/SERVICE_optical_character_recognition.md b/seed/resources/SERVICE_optical_character_recognition.md new file mode 100644 index 00000000..2f399f19 --- /dev/null +++ b/seed/resources/SERVICE_optical_character_recognition.md @@ -0,0 +1,6 @@ +# Optical Character Recognition — OCR API + +- **Type:** OCR +- **Port:** 8082 +- **Description:** HTTP server for optical character recognition. Compatible with OpenAI-style vision APIs. Configure the URL and model in the connection settings below. +- **Verify:** `curl -sf {url}/v1/models` diff --git a/seed/resources/SERVICE_speech_to_text.md b/seed/resources/SERVICE_speech_to_text.md new file mode 100644 index 00000000..ba47db5a --- /dev/null +++ b/seed/resources/SERVICE_speech_to_text.md @@ -0,0 +1,6 @@ +# Speech to Text — Transcription API + +- **Type:** STT +- **Port:** 8080 +- **Description:** HTTP server for speech-to-text transcription. Compatible with the whisper.cpp server API. Configure the URL in the connection settings below. +- **Verify:** `curl -sf {url}/health` diff --git a/seed/resources/SERVICE_text_to_speech.md b/seed/resources/SERVICE_text_to_speech.md new file mode 100644 index 00000000..df7ef8a1 --- /dev/null +++ b/seed/resources/SERVICE_text_to_speech.md @@ -0,0 +1,6 @@ +# Text to Speech — Synthesis API + +- **Type:** TTS +- **Port:** 8000 +- **Description:** HTTP server for text-to-speech synthesis. Compatible with the OpenAI audio/speech API (mlx-audio, Kokoro, etc.) and ElevenLabs. Configure the URL and credentials in the connection settings below. +- **Verify:** `curl -sf {url}/v1/models` diff --git a/seed/resources/optical-character-recognition/RESOURCE.md b/seed/resources/optical-character-recognition/RESOURCE.md new file mode 100644 index 00000000..1d59577c --- /dev/null +++ b/seed/resources/optical-character-recognition/RESOURCE.md @@ -0,0 +1,5 @@ +--- +name: Optical Character Recognition +description: HTTP server for optical character recognition +--- +Compatible with OpenAI-style vision APIs for extracting text from images. diff --git a/seed/resources/optical-character-recognition/config.json b/seed/resources/optical-character-recognition/config.json new file mode 100644 index 00000000..50da5443 --- /dev/null +++ b/seed/resources/optical-character-recognition/config.json @@ -0,0 +1,7 @@ +{ + "url": "", + "api_key": "", + "username": "", + "password": "", + "model": "" +} diff --git a/seed/resources/speech-to-text/RESOURCE.md b/seed/resources/speech-to-text/RESOURCE.md new file mode 100644 index 00000000..48be579f --- /dev/null +++ b/seed/resources/speech-to-text/RESOURCE.md @@ -0,0 +1,5 @@ +--- +name: Speech to Text +description: HTTP server for speech-to-text transcription +--- +Compatible with the whisper.cpp server API and OpenAI-compatible transcription endpoints. diff --git a/seed/resources/speech-to-text/config.json b/seed/resources/speech-to-text/config.json new file mode 100644 index 00000000..8d209abe --- /dev/null +++ b/seed/resources/speech-to-text/config.json @@ -0,0 +1,6 @@ +{ + "url": "", + "api_key": "", + "username": "", + "password": "" +} diff --git a/seed/resources/text-to-speech/RESOURCE.md b/seed/resources/text-to-speech/RESOURCE.md new file mode 100644 index 00000000..c883040a --- /dev/null +++ b/seed/resources/text-to-speech/RESOURCE.md @@ -0,0 +1,5 @@ +--- +name: Text to Speech +description: HTTP server for text-to-speech synthesis +--- +Compatible with OpenAI audio/speech API (mlx-audio, Kokoro, etc.) and ElevenLabs. diff --git a/seed/resources/text-to-speech/config.json b/seed/resources/text-to-speech/config.json new file mode 100644 index 00000000..8e068f2f --- /dev/null +++ b/seed/resources/text-to-speech/config.json @@ -0,0 +1,9 @@ +{ + "url": "", + "api_key": "", + "username": "", + "password": "", + "provider": "", + "model": "", + "voice": "" +} diff --git a/seed/tools/ocr/TOOL.md b/seed/tools/ocr/TOOL.md new file mode 100644 index 00000000..7d499e85 --- /dev/null +++ b/seed/tools/ocr/TOOL.md @@ -0,0 +1,27 @@ +--- +name: ocr +label: OCR +description: Extract text from an image file using the configured OCR service. Sends the image to the OCR API (OpenAI-compatible vision endpoint) and returns the extracted text. Use this tool whenever you need to read text from images, screenshots, documents, receipts, etc. Requires OCR to be configured in Settings → Resources. +language: typescript +inputs: + file_path: + type: string + description: Absolute path to the image file to extract text from + prompt: + type: string + description: Optional instructions for the OCR model (e.g. "extract only the table" or "return as markdown") + optional: true +--- + +# OCR Tool + +Extracts text from images using the configured OCR resource (OpenAI-compatible vision API). + +## Supported formats + +PNG, JPEG, WebP, GIF, and other common image formats. + +## Output + +Returns the extracted text content. For documents, preserves structure as markdown. +For tables, uses markdown table format. For code screenshots, uses fenced code blocks. diff --git a/seed/tools/ocr/index.ts b/seed/tools/ocr/index.ts new file mode 100644 index 00000000..8404b223 --- /dev/null +++ b/seed/tools/ocr/index.ts @@ -0,0 +1,112 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { extname } from 'node:path'; + +type OcrConfig = { + url: string; + model: string; + api_key?: string; +}; + +function getOcrConfig(): OcrConfig | null { + try { + const raw = process.env.OFFICER_RESOURCES; + if (!raw) return null; + const resources = JSON.parse(raw) as Record>; + const ocr = resources['optical-character-recognition']; + if (!ocr?.url) return null; + return { url: ocr.url, model: ocr.model ?? '', api_key: ocr.api_key }; + } catch { + return null; + } +} + +const DEFAULT_PROMPT = [ + 'You are an OCR assistant. Extract meaningful text content from images.', + 'Rules:', + '- Output ONLY the extracted text, no commentary or explanations.', + '- For documents, articles, books: preserve the original text, paragraphs, and structure as markdown.', + '- For tables: use markdown table format.', + '- For code/terminal screenshots: use fenced code blocks.', + '- For handwritten text: do your best to transcribe accurately.', + '- For mixed content: use appropriate formatting for each section.', +].join('\n'); + +export async function execute( + _toolCallId: string, + params: { file_path: string; prompt?: string }, +) { + const config = getOcrConfig(); + if (!config) { + return { + content: [{ type: 'text', text: 'OCR is not configured. Set it up in Settings → Resources → Optical Character Recognition.' }], + isError: true, + }; + } + + const { file_path, prompt } = params; + + if (!existsSync(file_path)) { + return { + content: [{ type: 'text', text: `File not found: ${file_path}` }], + isError: true, + }; + } + + const imageBytes = readFileSync(file_path); + const base64 = imageBytes.toString('base64'); + const ext = extname(file_path).replace('.', '').toLowerCase(); + const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : `image/${ext || 'png'}`; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (config.api_key) headers['Authorization'] = `Bearer ${config.api_key}`; + + const systemPrompt = prompt ? `${DEFAULT_PROMPT}\n\nAdditional instructions: ${prompt}` : DEFAULT_PROMPT; + + try { + const res = await fetch(`${config.url.replace(/\/+$/, '')}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: config.model, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: [ + { type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } }, + ], + }, + ], + max_tokens: 4096, + }), + }); + + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + return { + content: [{ type: 'text', text: `OCR API error (${res.status}): ${errorText}` }], + isError: true, + }; + } + + const json = await res.json() as { choices?: Array<{ message?: { content?: string } }> }; + const text = json.choices?.[0]?.message?.content ?? ''; + + if (!text) { + return { + content: [{ type: 'text', text: 'OCR returned empty result — the image may not contain readable text.' }], + isError: false, + }; + } + + return { + content: [{ type: 'text', text }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ type: 'text', text: `OCR request failed: ${message}` }], + isError: true, + }; + } +} diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx index d7e2f49d..da018194 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx @@ -6,13 +6,14 @@ import { useClient } from 'hooks/useClient'; type GoogleStatus = { connected: boolean; email: string | null; + picture: string | null; configured: boolean; }; export const GoogleAccount = () => { const client = useClient(); const [isLoading, setIsLoading] = useState(true); - const [status, setStatus] = useState({ connected: false, email: null, configured: false }); + const [status, setStatus] = useState({ connected: false, email: null, picture: null, configured: false }); const fetchStatus = () => { client @@ -76,6 +77,9 @@ export const GoogleAccount = () => {

Connected

{status.email}

+ {status.picture && ( + + )}

Officer has access to your Google Calendar, Gmail, and other enabled services. diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx index bd8e0b3b..e0ab80ef 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx @@ -1,116 +1,125 @@ import { useState } from 'react'; -import { Circle, Server, Wrench } from 'lucide-react'; +import { Server, Plus, Check, X, Search } from 'lucide-react'; import { useGlobal } from 'hooks/useGlobal'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { useResources, getResourceCategory, type Resource } from 'state/useResources'; - -type ResourceItemProps = { - resource: Resource; - isActive: boolean; - onSelect: () => void; -}; - -const ResourceItem = ({ resource: r, isActive, onSelect }: ResourceItemProps) => ( - -); +import { useResources, type ResourceSummary } from 'state/useResources'; export const ResourceSidebar = () => { - const { resources, isLoading } = useResources(); - const [selectedId, setSelectedId] = useGlobal('RESOURCE_SELECTED', null); - const [showCatalog, setShowCatalog] = useGlobal('RESOURCE_CATALOG', false); + const { resources, isLoading, createResource } = useResources(); + const [selectedName, setSelectedName] = useGlobal('RESOURCE_SELECTED', null); const [search, setSearch] = useState(''); - - const installed = resources?.filter((r: Resource) => r.installed) ?? []; + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(''); const query = search.toLowerCase(); - const filtered = query - ? installed.filter( - (r: Resource) => r.name.toLowerCase().includes(query) || r.subtitle.toLowerCase().includes(query), - ) - : installed; + const filtered = resources?.filter( + (r: ResourceSummary) => + !query || r.name.toLowerCase().includes(query) || r.description?.toLowerCase().includes(query), + ) ?? []; - const apiBased = filtered.filter((r: Resource) => getResourceCategory(r) === 'api-based'); - const localCli = filtered.filter((r: Resource) => getResourceCategory(r) === 'local-cli'); - - const handleCatalog = () => { - setSelectedId(null); - setShowCatalog(true); + const handleSelect = (dirName: string) => { + setSelectedName(dirName); }; - const handleSelect = (id: string) => { - setShowCatalog(false); - setSelectedId(id); + 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 (

-
-

Resources

- - setSearch(ev.target.value)} - className="h-7 text-xs" - /> +
+ Resources + {!creating && ( + + )}
-
- {isLoading &&

Loading...

} - {!isLoading && filtered.length === 0 && ( -

{search ? 'No matches' : 'No active resources'}

- )} - {apiBased.length > 0 && ( - <> -
- - API Based + {creating && ( +
+ 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 + /> + + +
+ )} +
+
+ + 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" + /> +
+
+
+ {isLoading &&

Loading...

} + {filtered.map((r: ResourceSummary) => ( + + ))} + {!isLoading && resources && resources.length === 0 && ( +

No resources found

)} - {localCli.length > 0 && ( - <> -
- - Local CLI -
- {localCli.map((r: Resource) => ( - handleSelect(r.id)} - /> - ))} - + {!isLoading && resources && resources.length > 0 && filtered.length === 0 && ( +

No matches

)}
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx index 1a84522c..82b976ee 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx @@ -1,42 +1,66 @@ -import { useState } from 'react'; -import { Server, Wrench, Loader2, RefreshCw, Trash2 } from 'lucide-react'; +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 { useGlobal } from 'hooks/useGlobal'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { Pencil, Trash2, Plus, X, Loader2, ArrowLeft } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { useResources, getResourceCategory, type Resource, type PingResult } from 'state/useResources'; -import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel'; +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'; -type ConnectionSectionProps = { - resource: Resource; +const isSensitiveKey = (key: string) => /key|secret|password|token/i.test(key); + +type ConfigEditorProps = { + resourceName: string; + config: Record; + onSaved: () => void; }; -const ConnectionSection = ({ resource }: ConnectionSectionProps) => { - const { saveConnectionConfig, pingResource } = useResources(); - const [url, setUrl] = useState(resource.connectionConfig?.url ?? ''); - const [apiKey, setApiKey] = useState(resource.connectionConfig?.credentials?.apiKey ?? ''); - const [username, setUsername] = useState(resource.connectionConfig?.credentials?.username ?? ''); - const [password, setPassword] = useState(resource.connectionConfig?.credentials?.password ?? ''); +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(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(resource.id, url); + const result = await pingResource(resourceName, url); setPingResult(result); } catch { setPingResult({ reachable: false, latencyMs: null }); @@ -48,74 +72,83 @@ const ConnectionSection = ({ resource }: ConnectionSectionProps) => { const handleSave = async () => { setSaving(true); try { - const credentials = - apiKey || username || password - ? { apiKey: apiKey || undefined, username: username || undefined, password: password || undefined } - : undefined; - await saveConnectionConfig(resource.id, { url, credentials }); + const oldKeys = Object.keys(config); + const newKeys = new Set(fields.map(([k]) => k)); + const patch: Record = {}; + 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); } }; - const hasCredentials = !!( - resource.connectionConfig?.credentials?.apiKey || resource.connectionConfig?.credentials?.username - ); - return (
-

Connection

-
-
- - setUrl(ev.target.value)} - placeholder="http://127.0.0.1:64202" - className="h-8 text-xs" - /> -
- {(hasCredentials || apiKey) && ( -
- +

Configuration

+
+ {fields.map(([key, value], index) => ( +
+ setApiKey(ev.target.value)} - placeholder="Optional" - type="password" - className="h-8 text-xs" + value={value} + onChange={(ev) => handleFieldValue(index, ev.target.value)} + type={isSensitiveKey(key) ? 'password' : 'text'} + className="h-8 text-xs flex-1" /> +
- )} - {(hasCredentials || username || password) && ( -
-
- - setUsername(ev.target.value)} - placeholder="Optional" - className="h-8 text-xs" - /> -
-
- - setPassword(ev.target.value)} - placeholder="Optional" - type="password" - className="h-8 text-xs" - /> -
-
- )} + ))}
- -
+
+ {hasUrl && ( + + )} + @@ -130,318 +163,187 @@ const ConnectionSection = ({ resource }: ConnectionSectionProps) => { ); }; -type LocalAvailabilitySectionProps = { - resource: Resource; - onRun: (command: string) => void; +type ResourceChatProps = { + detail: ResourceDetail; + isNew?: boolean; + onResponseEnd: () => void; }; -type ResourceAction = 'install' | 'uninstall' | 'verify' | 'update' | 'manage'; +const ResourceChat = ({ detail, isNew, onResponseEnd }: ResourceChatProps) => { + const promptFrontmatter = `\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`; + const defaultInput = isNew + ? 'Help me set up this new resource' + : 'Help me configure this resource'; -const LocalAvailabilitySection = ({ resource, onRun }: LocalAvailabilitySectionProps) => { - const { runCommand } = useResources(); - const [runningAction, setRunningAction] = useState(null); + const pi = usePiChat(undefined, undefined, { replaceUrl: false }); - const isSudo = (cmd: string) => cmd.trimStart().startsWith('sudo'); + const onResponseEndRef = useRef(onResponseEnd); + onResponseEndRef.current = onResponseEnd; - const handleAction = async (action: ResourceAction, command: string) => { - if (isSudo(command)) { - onRun(command); - return; + const wasGenerating = useRef(false); + useEffect(() => { + if (wasGenerating.current && !pi.isGenerating) { + onResponseEndRef.current(); } - setRunningAction(action); - try { - const result = await runCommand(resource.id, action); - if (result.exitCode === 0) { - toast.success('Command completed successfully'); - } else { - toast.error(result.output || `Command failed (exit code ${result.exitCode})`, { duration: 8000 }); - } - } catch { - toast.error('Failed to run command'); - } finally { - setRunningAction(null); - } - }; + wasGenerating.current = pi.isGenerating; + }, [pi.isGenerating]); return ( -
- {resource.installed ? ( - <> -
- Installed -
- {resource.version && ( -
- {resource.updateCommand && ( - - )} - {resource.version} -
- )} -
- {!resource.version && resource.updateCommand && ( - - )} - {resource.verifyCommand && ( - - )} - {resource.manageCommand && ( - - )} -
- {resource.uninstallCommand && ( -
- -
- )} - - ) : ( -
- Not installed - {resource.installCommand && ( - - )} -
- )} -
- ); -}; - -type CatalogCardProps = { - resource: Resource; - onSelect: (id: string) => void; -}; - -const CatalogCard = ({ resource: r, onSelect }: CatalogCardProps) => { - const category = getResourceCategory(r); - return ( - - ); -}; - -const ResourceCatalog = () => { - const { resources, isLoading } = useResources(); - const [, setSelectedId] = useGlobal('RESOURCE_SELECTED', null); - const [, setShowCatalog] = useGlobal('RESOURCE_CATALOG', false); - const [search, setSearch] = useState(''); - - const query = search.toLowerCase(); - const filtered = - resources?.filter( - (r: Resource) => - !r.installed && - (r.name.toLowerCase().includes(query) || - r.subtitle.toLowerCase().includes(query) || - r.description.toLowerCase().includes(query)), - ) ?? []; - - const apiBased = filtered.filter((r: Resource) => getResourceCategory(r) === 'api-based'); - const localCli = filtered.filter((r: Resource) => getResourceCategory(r) === 'local-cli'); - - const handleSelect = (id: string) => { - setShowCatalog(false); - setSelectedId(id); - }; - - return ( -
-

Resource Catalog

-

All available resources. Select one to configure.

- setSearch(ev.target.value)} - className="h-8 text-xs mb-4 max-w-xs" - /> - {isLoading &&

Loading...

} - {apiBased.length > 0 && ( -
-
- - API Based -
-
- {apiBased.map((r: Resource) => ( - - ))} -
-
- )} - {localCli.length > 0 && ( -
-
- - Local CLI -
-
- {localCli.map((r: Resource) => ( - - ))} -
-
- )} -
- ); -}; - -const ResourceDetail = ({ resource }: { resource: Resource }) => { - const category = getResourceCategory(resource); - const [, setRunCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); - const [confirmCommand, setConfirmCommand] = useState(null); - - const handleRun = (command: string) => { - const isSudo = command.trimStart().startsWith('sudo'); - if (isSudo) { - setConfirmCommand(command); - } else { - setRunCommand({ command }); - } - }; - - return ( - <> -
-
- {resource.port ? ( - - ) : ( - - )} -

{resource.name}

- {resource.subtitle} -
- -
- {resource.type} - - {category === 'api-based' ? 'API Based' : 'Local CLI'} - - {resource.port && ( - :{resource.port} - )} -
- -

{resource.description}

- - {category === 'api-based' && } - -
- - !open && setConfirmCommand(null)}> - - - Run with elevated privileges - -

- For this operation the script must be run with elevated privileges (sudo) on the host machine. -
- Not to worry, though, we wrote it and battle tested it ourselves. -

- {confirmCommand} - - Cancel - { - if (confirmCommand) setRunCommand({ command: confirmCommand }); - setConfirmCommand(null); - }} - > - Run - - -
-
- + ); }; export const Resources = () => { - const { resources } = useResources(); - const [selectedId] = useGlobal('RESOURCE_SELECTED', null); - const [showCatalog] = useGlobal('RESOURCE_CATALOG', false); + const client = useClient(); + const qc = useQueryClient(); + const [selectedName] = useGlobal('RESOURCE_SELECTED', null); + const [editing, setEditing] = useState(false); + const [isNew, setIsNew] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [showDetail, setShowDetail] = useState(false); - const resource = selectedId ? resources?.find((r: Resource) => r.id === selectedId) : null; + const { data: detail, refetch } = useQuery({ + queryKey: ['RESOURCES', selectedName], + queryFn: () => client.get(`/server-settings/resources/${selectedName}`), + enabled: !!selectedName, + }); - if (showCatalog || !resource) return ; - return ; + 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 ( +
+

Select a resource to view its configuration

+
+ ); + } + + return ( + <> +
+ {/* Detail panel */} +
+
+ + {detail.name} + + {detail.scope} + + + {canDelete && ( + + )} +
+
+ {detail.rawFrontmatter && } + {detail.body && ( +
+ + {detail.body} + +
+ )} + refetch()} + /> +
+
+ + {/* Chat panel */} + {editing && ( +
+
+ + {detail.name} — Chat + +
+ { + refetch(); + qc.invalidateQueries({ queryKey: ['RESOURCES'] }); + }} + /> +
+ )} +
+ + + + + Delete Resource + + Are you sure you want to delete "{detail.name}"? This action cannot be undone. + + +
+ + +
+
+
+ + ); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx index 36aa901a..84493f0a 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx @@ -1,22 +1,11 @@ -import { useState, useEffect, useMemo } from 'react'; -import { X } from 'lucide-react'; -import { toast } from 'sonner'; -import { useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; import type { LayoutNode, PanelComponents } from 'officerdev'; -import { WorkspaceLayout, TerminalView, FileViewerView } from 'officerdev'; -import { usePanelChannel } from 'hooks/usePanelChannel'; -import { useClient } from 'hooks/useClient'; +import { WorkspaceLayout } from 'officerdev'; import { Resources } from './Resources'; import { ResourceSidebar } from './ResourceSidebar'; -import { - RUN_COMMAND_CHANNEL, - ERROR_LOG_CHANNEL, - type RunCommandState, - type ErrorLogState, -} from './run-command-channel'; -const baseLayout: LayoutNode = { +const layout: LayoutNode = { type: 'group', id: 'resources-root', direction: 'horizontal', @@ -26,147 +15,11 @@ const baseLayout: LayoutNode = { ], }; -const splitLayout: LayoutNode = { - type: 'group', - id: 'resources-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 }, - { - node: { - type: 'group', - id: 'resources-right-group', - direction: 'vertical', - children: [ - { node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 }, - { node: { type: 'panel', id: 'resources-terminal', appType: null }, size: 50 }, - ], - }, - size: 80, - }, - ], -}; - -const errorLayout: LayoutNode = { - type: 'group', - id: 'resources-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 }, - { - node: { - type: 'group', - id: 'resources-right-group', - direction: 'vertical', - children: [ - { node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 }, - { node: { type: 'panel', id: 'resources-error-log', appType: null }, size: 50 }, - ], - }, - size: 80, - }, - ], -}; - -const ResourceTerminalPanel = () => { - const queryClient = useQueryClient(); - const client = useClient(); - const [state, setState] = usePanelChannel(RUN_COMMAND_CHANNEL, null); - const [, setErrorLog] = usePanelChannel(ERROR_LOG_CHANNEL, null); - const [session, setSession] = useState<{ id: string; command: string } | null>(null); - - useEffect(() => { - if (state && (!session || session.command !== state.command)) { - setSession({ id: `res-cmd-${Date.now()}`, command: state.command }); - } else if (!state) { - setSession(null); - } - }, [state]); - - const close = () => setState(null); - - const onCommandDone = (exitCode: number, output: string) => { - queryClient.invalidateQueries({ queryKey: ['RESOURCES'] }); - if (exitCode === 0) { - toast.success('Command completed successfully'); - setTimeout(() => setState(null), 2000); - } else { - const command = session?.command ?? 'unknown'; - const md = [ - `# Command Failed (exit code ${exitCode})`, - '', - '```', - command, - '```', - '', - '## Output', - '', - '```', - output, - '```', - ].join('\n'); - - client.post('/server-settings/resources/error-log', { command, output, exitCode }).catch(() => {}); - - setState(null); - setErrorLog({ content: md, fileName: 'error.md' }); - } - }; - - if (!state || !session) return null; - - return ( -
-
- Run Command - -
- -
- ); -}; - -const ErrorLogPanel = () => { - const [errorLog, setErrorLog] = usePanelChannel(ERROR_LOG_CHANNEL, null); - - if (!errorLog) return null; - - return ( - setErrorLog(null)} - /> - ); -}; - export const ResourceSettings = () => { - const [runCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); - const [errorLog] = usePanelChannel(ERROR_LOG_CHANNEL, null); - - const layout = useMemo( - () => (errorLog ? errorLayout : runCommand ? splitLayout : baseLayout), - [errorLog, runCommand], - ); - const panelComponents: PanelComponents = useMemo( () => ({ 'resources-left': ResourceSidebar, 'resources-right': Resources, - 'resources-terminal': ResourceTerminalPanel, - 'resources-error-log': ErrorLogPanel, }), [], ); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/run-command-channel.ts b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/run-command-channel.ts deleted file mode 100644 index 4aedd472..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/run-command-channel.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type RunCommandState = { - command: string; -} | null; - -export const RUN_COMMAND_CHANNEL = 'resource-settings:run-command'; - -export type ErrorLogState = { - content: string; - fileName: string; -} | null; - -export const ERROR_LOG_CHANNEL = 'resource-settings:error-log'; diff --git a/src/servers/api/integrations/integrations.ts b/src/servers/api/integrations/integrations.ts index cb4899e0..7537ee61 100644 --- a/src/servers/api/integrations/integrations.ts +++ b/src/servers/api/integrations/integrations.ts @@ -106,6 +106,7 @@ integrationsRouter.get('/google/status', async (ctx) => { configured: !!(config?.clientId && config?.clientSecret), connected: !!connection?.accessToken, email: connection?.email ?? null, + picture: connection?.picture ?? null, }); }); @@ -201,9 +202,11 @@ export const googleCallbackHandler = async (ctx: any) => { }); let googleEmail = email; + let picture: string | null = null; if (userinfoResponse.ok) { const userinfo = await userinfoResponse.json(); googleEmail = userinfo.email ?? email; + picture = userinfo.picture ?? null; } await writeUserGoogle(email, { @@ -211,6 +214,7 @@ export const googleCallbackHandler = async (ctx: any) => { refreshToken: tokens.refresh_token, expiresAt: Date.now() + tokens.expires_in * 1000, email: googleEmail, + picture, scope: tokens.scope, }); diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 43e6becc..1403d62e 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -1,12 +1,13 @@ import { join, relative } from "path"; -import { readdirSync, existsSync, mkdirSync } from "node:fs"; +import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import type { Subprocess } from "bun"; import type { PiEvent, MessageCost } from "./types"; import { readApiKeys } from "../server-settings/pi-mono"; import { readSearxngConfig } from "../server-settings/searxng"; -import { PI_CONFIG_DIR, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir } from "../../data-path"; +import { PI_CONFIG_DIR, DATA_PATH, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path"; import { ensureDockerContainer } from "../terminal/websocket"; import { logger } from "./logger"; +import { parseFrontmatter } from "../skills/skills"; export type PiEventHandler = (event: PiEvent) => void; @@ -52,6 +53,106 @@ function collectExtensionFlags(email: string, containerPaths?: PathOverrides): s return flags; } +function generateResourceSkill(outputDir: string): string | null { + const nativeDir = getNativeResourcesDir(); + const globalDir = getGlobalResourcesDir(); + + // Collect all resource dirs (global overrides native) + const resourceDirs = new Map(); + 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 = {}; + let globalConfig: Record = {}; + 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 = {}; + 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'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8'); + return skillDir; +} + +function buildResourcesEnv(): string { + const nativeDir = getNativeResourcesDir(); + const globalDir = getGlobalResourcesDir(); + + const resourceDirs = new Map(); + 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> = {}; + for (const [name] of resourceDirs) { + let nativeConfig: Record = {}; + let globalConfig: Record = {}; + 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 = {}; + 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); +} + type SandboxOptions = { userId: number; username: string; @@ -87,19 +188,27 @@ export async function spawnPi( user: '/officer/user/extensions', }); + // Generate resource context skill (host-side, mounted into container) + const resourceSkillHost = generateResourceSkill(DATA_PATH); + const resourceSkillFlags = resourceSkillHost ? ['--skill', '/officer/generated/available-resources'] : []; + const piArgs = [ 'pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, + ...resourceSkillFlags, ]; if (model) piArgs.push('--model', model); + const resourcesEnv = buildResourcesEnv(); + const envFlags = [ '-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`, '-e', `HOME=${containerHome}`, '-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`, '-e', `PI_SEARXNG_URL=${searxng.url}`, + '-e', `OFFICER_RESOURCES=${resourcesEnv}`, ]; for (const [key, value] of Object.entries(storedKeys)) { if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`); @@ -130,7 +239,12 @@ export async function spawnPi( const searxng = await readSearxngConfig(); const skillFlags = collectSkillFlags(email); const extensionFlags = collectExtensionFlags(email); - const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags]; + + // Generate resource context skill + const resourceSkillDir = generateResourceSkill(DATA_PATH); + const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : []; + + const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags]; if (model) args.push('--model', model); if (!existsSync(cwd)) { @@ -144,7 +258,7 @@ export async function spawnPi( stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', - env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url }, + env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url, OFFICER_RESOURCES: buildResourcesEnv() }, }); logger.info('Spawned Pi locally', { diff --git a/src/servers/api/server-settings/ocr.ts b/src/servers/api/server-settings/ocr.ts index 948ecc24..94246c5b 100644 --- a/src/servers/api/server-settings/ocr.ts +++ b/src/servers/api/server-settings/ocr.ts @@ -1,5 +1,6 @@ import { createRouter } from '../../create-router'; import { settingsPath } from './server-settings'; +import { readResourceConfig } from './resources'; type OcrConfig = { url: string; @@ -7,6 +8,9 @@ type OcrConfig = { }; export async function readOcrConfig(): Promise { + const config = await readResourceConfig('optical-character-recognition'); + if (config.url) return { url: config.url, model: config.model ?? '' }; + // Fallback to legacy settings.json const settings = await Bun.file(settingsPath).json().catch(() => ({})); return settings.ocr as OcrConfig | undefined; } diff --git a/src/servers/api/server-settings/resources.ts b/src/servers/api/server-settings/resources.ts index 3c5d74d1..47ad83aa 100644 --- a/src/servers/api/server-settings/resources.ts +++ b/src/servers/api/server-settings/resources.ts @@ -1,259 +1,224 @@ -import { readdir, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { readdir, mkdir, rm } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; import { createRouter } from '../../create-router'; -import { DATA_PATH, getResourcesDir } from '../../data-path'; +import { getNativeResourcesDir, getGlobalResourcesDir } from '../../data-path'; +import { parseFrontmatter } from '../skills/skills'; -type ResourceCredentials = { - apiKey?: string; - username?: string; - password?: string; -}; - -export type ResourceConnectionConfig = { - url: string; - credentials?: ResourceCredentials; -}; - -type ResourcesConfig = Record; - -type Resource = { - id: string; - name: string; - subtitle: string; - type: string; - port: string | null; - path: string | null; - description: string; - installCommand: string | null; - uninstallCommand: string | null; - manageCommand: string | null; - verifyCommand: string | null; - updateCommand: string | null; - installed: boolean; - version: string | null; - connectionConfig: ResourceConnectionConfig | null; -}; - -const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1'); - -const resolveCommand = (command: string): string => { - return command.replace(/\$DATA_PATH/g, DATA_PATH); -}; - -function parseResourceFile( - filename: string, - content: string, -): Omit { - const id = filename - .replace(/^SERVICE_/, '') - .replace(/\.md$/, '') - .toLowerCase() - .replace(/_/g, '-'); - - const headingMatch = content.match(/^#\s+(.+?)\s+—\s+(.+)$/m); - const name = headingMatch?.[1] ?? id; - const subtitle = headingMatch?.[2] ?? ''; - - const field = (key: string): string | null => { - const match = content.match(new RegExp(`^-\\s+\\*\\*${key}:\\*\\*\\s+(.+)$`, 'm')); - return match?.[1]?.trim() ?? null; - }; - - const rawType = field('Type') ?? 'native'; - const rawPort = field('Port'); - const port = rawPort && !rawPort.startsWith('none') ? rawPort : null; - - const rawPath = field('Path'); - - return { - id, - name, - subtitle, - type: rawType, - port, - path: rawPath ? stripBackticks(rawPath) : null, - description: field('Description') ?? '', - installCommand: field('Install') ? resolveCommand(stripBackticks(field('Install')!)) : null, - uninstallCommand: field('Uninstall') ? resolveCommand(stripBackticks(field('Uninstall')!)) : null, - manageCommand: field('Manage') ? resolveCommand(stripBackticks(field('Manage')!)) : null, - verifyCommand: field('Verify') ? resolveCommand(stripBackticks(field('Verify')!)) : null, - updateCommand: field('Update') ? resolveCommand(stripBackticks(field('Update')!)) : null, - }; +async function readResourceDirs(dir: string): Promise> { + const result = new Map(); + 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; } -const CONFIG_FILENAME = 'resources-config.json'; - -const getConfigPath = () => join(getResourcesDir(), CONFIG_FILENAME); - -export async function readConfig(): Promise { - const path = getConfigPath(); - if (!existsSync(path)) return {}; - const text = await Bun.file(path).text(); - return JSON.parse(text) as ResourcesConfig; +async function readConfigFile(dir: string): Promise> { + try { + return await Bun.file(join(dir, 'config.json')).json(); + } catch { + return {}; + } } -async function writeConfig(config: ResourcesConfig): Promise { - const dir = getResourcesDir(); - if (!existsSync(dir)) await mkdir(dir, { recursive: true }); - await Bun.write(getConfigPath(), JSON.stringify(config, null, 2)); +function mergeConfig(native: Record, global: Record): Record { + const merged: Record = {}; + 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 === 'Admin' || role === 'Owner' || role === 'Super Admin'; +} + +export async function readResourceConfig(name: string): Promise> { + const nativeConfig = await readConfigFile(join(getNativeResourcesDir(), name)); + const globalConfig = await readConfigFile(join(getGlobalResourcesDir(), name)); + return mergeConfig(nativeConfig, globalConfig); } const CHECK_TIMEOUT_MS = 3_000; -async function checkPort(port: number): Promise { - try { - const socket = await Bun.connect({ - hostname: '127.0.0.1', - port, - socket: { - data() {}, - open(s) { - s.end(); - }, - error() {}, - }, - }); - socket.end(); - return true; - } catch { - return false; - } -} - -async function checkVerifyCommand(command: string): Promise<{ installed: boolean; version: string | null }> { - try { - const proc = Bun.spawn(['sh', '-c', command], { stdout: 'pipe', stderr: 'pipe' }); - const timeout = new Promise((_, reject) => - setTimeout(() => { - proc.kill(); - reject(new Error('timeout')); - }, CHECK_TIMEOUT_MS), - ); - const result = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); - const [stdout, stderr] = await Promise.race([result, timeout]); - if (proc.exitCode !== 0) return { installed: false, version: null }; - const output = (stdout + stderr).trim(); - const versionMatch = output.match(/(\d+\.\d+[\w.-]*)/); - return { installed: true, version: versionMatch?.[1] ?? null }; - } catch { - return { installed: false, version: null }; - } -} - -function extractFirstPort(portStr: string): number | null { - const match = portStr.match(/(\d+)/); - return match ? parseInt(match[1]!, 10) : null; -} - -async function checkUrl(url: string): Promise { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); - await fetch(url, { signal: controller.signal }); - clearTimeout(timeout); - return true; - } catch { - return false; - } -} - -type CheckResourceStatusParams = { - resource: Omit; - configUrl?: string; -}; - -async function checkResourceStatus({ - resource, - configUrl, -}: CheckResourceStatusParams): Promise<{ installed: boolean; version: string | null }> { - if (resource.path) { - const fullPath = `${getResourcesDir()}/${resource.path}`; - return { installed: existsSync(fullPath), version: null }; - } - if (resource.port) { - if (configUrl) { - const reachable = await checkUrl(configUrl); - if (reachable) return { installed: true, version: null }; - } - const port = extractFirstPort(resource.port); - if (port) { - const reachable = await checkPort(port); - return { installed: reachable, version: null }; - } - } - if (resource.verifyCommand) { - return checkVerifyCommand(resource.verifyCommand); - } - return { installed: false, version: null }; -} - -function buildConnectionConfig( - resource: Omit, - config: ResourcesConfig, -): ResourceConnectionConfig | null { - if (!resource.port) return null; - if (config[resource.id]) return config[resource.id]!; - const port = extractFirstPort(resource.port); - return { url: `http://127.0.0.1:${port ?? resource.port}` }; -} - -async function parseResources() { - const dir = getResourcesDir(); - if (!existsSync(dir)) return []; - const files = await readdir(dir); - const serviceFiles = files.filter((f) => f.startsWith('SERVICE_') && f.endsWith('.md')); - return Promise.all( - serviceFiles.map(async (filename) => { - const content = await Bun.file(`${dir}/${filename}`).text(); - return parseResourceFile(filename, content); - }), - ); -} - -async function loadResources(): Promise { - const [parsed, config] = await Promise.all([parseResources(), readConfig()]); - return Promise.all( - parsed.map(async (r) => { - const configUrl = config[r.id]?.url; - const status = await checkResourceStatus({ resource: r, configUrl }); - const connectionConfig = buildConnectionConfig(r, config); - return { ...r, ...status, connectionConfig }; - }), - ); -} - export const resourcesRouter = createRouter(); resourcesRouter.get('/', async (ctx) => { - const resources = await loadResources(); + 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('/config', async (ctx) => { - const config = await readConfig(); - return ctx.json(config); +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('/config/:id', async (ctx) => { - const id = ctx.req.param('id'); - const body = await ctx.req.json>(); - const config = await readConfig(); - const existing = config[id] ?? { url: '' }; - config[id] = { ...existing, ...body }; - await writeConfig(config); - return ctx.json(config[id]); +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>(); + + 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('/:id/ping', async (ctx) => { - const id = ctx.req.param('id'); +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 readConfig(); - const resources = await loadResources(); - const resource = resources.find((r) => r.id === id); - if (!resource) return ctx.json({ error: 'Resource not found' }, 404); + const config = await readResourceConfig(name); - const url = body.url ?? config[id]?.url ?? resource.connectionConfig?.url; + const url = body.url ?? config.url; if (!url) return ctx.json({ error: 'No URL configured' }, 400); try { @@ -268,67 +233,3 @@ resourcesRouter.post('/:id/ping', async (ctx) => { return ctx.json({ reachable: false, latencyMs: null }); } }); - -resourcesRouter.post('/error-log', async (ctx) => { - const body = await ctx.req.json<{ command: string; output: string; exitCode: number }>(); - const timestamp = Date.now(); - const filePath = `/tmp/officer-error-${timestamp}.md`; - const md = [ - `# Command Failed (exit code ${body.exitCode})`, - '', - '```', - body.command, - '```', - '', - '## Output', - '', - '```', - body.output, - '```', - ].join('\n'); - await Bun.write(filePath, md); - return ctx.json({ filePath }); -}); - -resourcesRouter.post('/:id/run', async (ctx) => { - const id = ctx.req.param('id'); - const { action } = await ctx.req.json<{ action: string }>(); - - const parsed = await parseResources(); - const resource = parsed.find((r) => r.id === id); - if (!resource) return ctx.json({ error: 'Resource not found' }, 404); - - const commands: Record = { - install: resource.installCommand, - uninstall: resource.uninstallCommand, - verify: resource.verifyCommand, - update: resource.updateCommand, - manage: resource.manageCommand, - }; - - const command = commands[action]; - if (!command) return ctx.json({ error: `No ${action} command for this resource` }, 400); - - if (command.trimStart().startsWith('sudo')) { - return ctx.json({ error: 'Sudo commands must run in terminal' }, 400); - } - - try { - const proc = Bun.spawn(['sh', '-c', command], { stdout: 'pipe', stderr: 'pipe' }); - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - await proc.exited; - return ctx.json({ exitCode: proc.exitCode, output: (stdout + stderr).trim() }); - } catch { - return ctx.json({ exitCode: 1, output: 'Failed to execute command' }); - } -}); - -resourcesRouter.get('/:id', async (ctx) => { - const resources = await loadResources(); - const resource = resources.find((r) => r.id === ctx.req.param('id')); - if (!resource) return ctx.json({ error: 'Resource not found' }, 404); - return ctx.json(resource); -}); diff --git a/src/servers/api/server-settings/stt.ts b/src/servers/api/server-settings/stt.ts index c04edd73..ae73fcaf 100644 --- a/src/servers/api/server-settings/stt.ts +++ b/src/servers/api/server-settings/stt.ts @@ -1,11 +1,15 @@ import { createRouter } from '../../create-router'; import { settingsPath } from './server-settings'; +import { readResourceConfig } from './resources'; type SttConfig = { url: string; }; export async function readSttConfig(): Promise { + const config = await readResourceConfig('speech-to-text'); + if (config.url) return { url: config.url }; + // Fallback to legacy settings.json const settings = await Bun.file(settingsPath).json().catch(() => ({})); return settings.stt as SttConfig | undefined; } diff --git a/src/servers/api/server-settings/tts.ts b/src/servers/api/server-settings/tts.ts index ff417a10..93a3bdaa 100644 --- a/src/servers/api/server-settings/tts.ts +++ b/src/servers/api/server-settings/tts.ts @@ -1,5 +1,6 @@ import { createRouter } from '../../create-router'; import { settingsPath } from './server-settings'; +import { readResourceConfig } from './resources'; type TtsConfig = { provider: 'openai' | 'elevenlabs'; @@ -15,8 +16,19 @@ function maskSecret(value: string | undefined): string | undefined { } export async function readTtsConfig(): Promise { - const settings = await Bun.file(settingsPath).json().catch(() => ({})); - return settings.tts as TtsConfig | undefined; + const config = await readResourceConfig('text-to-speech'); + if (!config.url && !config.provider) { + // Fallback to legacy settings.json + const settings = await Bun.file(settingsPath).json().catch(() => ({})); + return settings.tts as TtsConfig | undefined; + } + return { + provider: (config.provider || 'openai') as 'openai' | 'elevenlabs', + url: config.url ?? '', + apiKey: config.api_key || undefined, + model: config.model ?? '', + voice: config.voice ?? '', + }; } export const ttsRouter = createRouter(); diff --git a/src/servers/api/terminal/Dockerfile.terminal-sidecar b/src/servers/api/terminal/Dockerfile.terminal-sidecar index 8c60eb8d..9e536be6 100644 --- a/src/servers/api/terminal/Dockerfile.terminal-sidecar +++ b/src/servers/api/terminal/Dockerfile.terminal-sidecar @@ -2,7 +2,7 @@ FROM imbios/bun-node:22-slim RUN apt-get update \ && apt-get install -y \ - python3 make gcc g++ zsh git curl wget ca-certificates \ + python3 python3-pip python3-venv make gcc g++ zsh git curl wget ca-certificates \ sudo gosu locales \ zip unzip tree btop net-tools tmux \ procps psmisc lsof less file man-db \ @@ -50,6 +50,19 @@ RUN curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LA && rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md +ENV GOLANG_VERSION=1.23.6 +RUN curl -fsSL "https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tar.gz \ + && tar -C /usr/local -xzf /tmp/go.tar.gz \ + && rm /tmp/go.tar.gz + +ENV PATH="/usr/local/go/bin:${PATH}" + +ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal \ + && chmod -R a+rw $CARGO_HOME + +ENV PATH="/usr/local/cargo/bin:${PATH}" + RUN npm install -g @mariozechner/pi-coding-agent WORKDIR /tmp diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index 08237792..b93cce92 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun'; import { mkdirSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir } from '@@/data-path'; +import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH } from '@@/data-path'; import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config'; import { getUsers } from 'officerdb'; @@ -163,6 +163,7 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, usern '-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`, '-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`, '-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`, + '-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`, '-w', containerHome, tag, ], diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index 0eec05df..721b7b6b 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -8,6 +8,8 @@ import { initAuthStore } from 'officerdb'; 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'; mkdirSync(DATA_PATH, { recursive: true }); mkdirSync(PI_CONFIG_DIR, { recursive: true }); @@ -75,6 +77,8 @@ function seedPiConfig(): void { syncSeedSkills(); syncSeedTools(); syncSeedExtensions(); + syncSeedResources(); + migrateSettingsToResources(); await syncLocalProvidersToPiConfig().catch(err => { console.error('[bootstrap] Failed to sync local providers to Pi config:', err); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 7bb213c9..4d697853 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -74,6 +74,10 @@ 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'); diff --git a/src/servers/migrate-resources.ts b/src/servers/migrate-resources.ts new file mode 100644 index 00000000..e503a9dd --- /dev/null +++ b/src/servers/migrate-resources.ts @@ -0,0 +1,53 @@ +import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DATA_PATH, SEED_PATH } from './data-path'; +import { settingsPath } from './api/server-settings/server-settings'; + +const SETTINGS_TO_RESOURCE: Record = { + stt: 'speech-to-text', + tts: 'text-to-speech', + ocr: 'optical-character-recognition', +}; + +export function migrateSettingsToResources(): void { + let settings: Record> = {}; + try { + settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + } 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 = {}; + 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 = {}; + 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`); + } +} diff --git a/src/servers/sync-processes.ts b/src/servers/sync-processes.ts new file mode 100644 index 00000000..4d242566 --- /dev/null +++ b/src/servers/sync-processes.ts @@ -0,0 +1,32 @@ +import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs'; +import { join } from 'node:path'; +import { SEED_PATH, DATA_PATH } from './data-path'; + +const SEED_PROCESSES_DIR = join(SEED_PATH, 'processes'); +const GLOBAL_PROCESSES_DIR = join(DATA_PATH, 'processes'); + +export function syncSeedProcesses(): void { + if (!existsSync(SEED_PROCESSES_DIR)) return; + + mkdirSync(GLOBAL_PROCESSES_DIR, { recursive: true }); + + const seedEntries = readdirSync(SEED_PROCESSES_DIR, { withFileTypes: true }); + + for (const entry of seedEntries) { + if (!entry.isDirectory()) continue; + + const seedProcessDir = join(SEED_PROCESSES_DIR, entry.name); + const processFile = join(seedProcessDir, 'PROCESS.md'); + if (!existsSync(processFile)) continue; + + const targetDir = join(GLOBAL_PROCESSES_DIR, entry.name); + + if (existsSync(targetDir)) { + // Process already exists in DATA_PATH — skip to preserve user edits + continue; + } + + cpSync(seedProcessDir, targetDir, { recursive: true }); + console.log(`[processes] Synced seed process: ${entry.name}`); + } +} diff --git a/src/servers/sync-resources.ts b/src/servers/sync-resources.ts new file mode 100644 index 00000000..0e33c209 --- /dev/null +++ b/src/servers/sync-resources.ts @@ -0,0 +1,9 @@ +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 }); +} diff --git a/src/servers/sync-tasks.ts b/src/servers/sync-tasks.ts new file mode 100644 index 00000000..47d69492 --- /dev/null +++ b/src/servers/sync-tasks.ts @@ -0,0 +1,32 @@ +import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs'; +import { join } from 'node:path'; +import { SEED_PATH, DATA_PATH } from './data-path'; + +const SEED_TASKS_DIR = join(SEED_PATH, 'tasks'); +const GLOBAL_TASKS_DIR = join(DATA_PATH, 'tasks'); + +export function syncSeedTasks(): void { + if (!existsSync(SEED_TASKS_DIR)) return; + + mkdirSync(GLOBAL_TASKS_DIR, { recursive: true }); + + const seedEntries = readdirSync(SEED_TASKS_DIR, { withFileTypes: true }); + + for (const entry of seedEntries) { + if (!entry.isDirectory()) continue; + + const seedTaskDir = join(SEED_TASKS_DIR, entry.name); + const taskFile = join(seedTaskDir, 'TASK.md'); + if (!existsSync(taskFile)) continue; + + const targetDir = join(GLOBAL_TASKS_DIR, entry.name); + + if (existsSync(targetDir)) { + // Task already exists in DATA_PATH — skip to preserve user edits + continue; + } + + cpSync(seedTaskDir, targetDir, { recursive: true }); + console.log(`[tasks] Synced seed task: ${entry.name}`); + } +} diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx index 87e6cec4..8fcc07e6 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx @@ -6,7 +6,7 @@ type TaskRunnerDialogProps = { }; export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps) => { - const { runningTask, setRunningTask, refresh, homeRoot, currentPath } = fileBrowserManager; + const { runningTask, setRunningTask, refresh, homeRoot, currentPath, getEntryAbsPath } = fileBrowserManager; if (!runningTask) return null; @@ -21,6 +21,7 @@ export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps) }} task={runningTask.task} entryName={runningTask.entry.name} + entryFullPath={getEntryAbsPath(runningTask.entry.name)} entryType={runningTask.entry.type} cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }} /> diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index d077937f..b1974cfc 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -1,11 +1,12 @@ -import { useEffect, useRef } from 'react'; -import { X } from 'lucide-react'; +import { useState, useEffect, useRef } from 'react'; +import { X, Play, Square, CircleCheck } from 'lucide-react'; import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { cardStyle } from '@/components/Card'; -import type { TaskInfo } from '../../../Chat'; -import { usePiChat, EmbeddableChat } from '../../../Chat'; +import type { TaskInfo, ChatMessage } from '../../../Chat'; +import { usePiChat, MessageBubble, StreamingBubble, ModelSelector } from '../../../Chat'; import { useSettings } from 'state/useSettings'; +import { useVisiblePiModels } from 'state/useModels'; import type { TaskSummary } from '../../useTasks'; const playDing = () => { @@ -32,6 +33,8 @@ const playDing = () => { setTimeout(() => ctx.close(), 1500); }; +type Phase = 'ready' | 'running' | 'done'; + type PiMonoInnerProps = { defaultInput: string; cwd: { root?: string; path: string }; @@ -39,27 +42,139 @@ type PiMonoInnerProps = { taskInfo: TaskInfo; }; -const PiMonoInner = ({ - defaultInput, - cwd, - initialModel, - taskInfo, -}: PiMonoInnerProps) => { +const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo }: PiMonoInnerProps) => { + const [phase, setPhase] = useState('ready'); const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo }); + const availableModels = useVisiblePiModels(); + // --- Independent message accumulator (never loses messages) --- + const accRef = useRef([]); + const seenToolIdsRef = useRef(new Set()); + const seenResultRef = useRef(false); + const [, bump] = useState(0); + + // Track tool/result messages from chat.messages (idempotent during render) + for (const m of chat.messages) { + if (m.role === 'tool' && 'toolCallId' in m) { + if (!seenToolIdsRef.current.has(m.toolCallId)) { + seenToolIdsRef.current.add(m.toolCallId); + accRef.current.push(m); + } else { + // Update existing tool message (e.g. output arrived) + const idx = accRef.current.findIndex( + (a) => a.role === 'tool' && 'toolCallId' in a && a.toolCallId === m.toolCallId, + ); + if (idx >= 0) accRef.current[idx] = m; + } + } + if (m.role === 'result' && !seenResultRef.current) { + seenResultRef.current = true; + accRef.current.push(m); + } + } + + // Capture assistant text when streaming is committed (streamingText goes non-empty → empty) + const lastStreamRef = useRef(''); + useEffect(() => { + if (lastStreamRef.current && !chat.streamingText) { + const text = lastStreamRef.current; + const isDuplicate = accRef.current.some((a) => a.role === 'assistant' && 'text' in a && a.text === text); + if (!isDuplicate) { + accRef.current.push({ role: 'assistant', text }); + bump((n) => n + 1); + } + lastStreamRef.current = ''; + } + if (chat.streamingText) { + lastStreamRef.current = chat.streamingText; + } + }, [chat.streamingText]); + + // Auto-scroll + const bottomRef = useRef(null); + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [chat.messages, chat.streamingText]); + + // Ding on completion + transition to done const wasGenerating = useRef(false); useEffect(() => { - if (wasGenerating.current && !chat.isGenerating) playDing(); + if (wasGenerating.current && !chat.isGenerating) { + playDing(); + setPhase('done'); + } wasGenerating.current = chat.isGenerating; }, [chat.isGenerating]); + const handleRun = () => { + setPhase('running'); + chat.sendPrompt(defaultInput, undefined, undefined, cwd); + }; + + if (phase === 'ready') { + return ( + <> +
+ +
+
+ +
+ + ); + } + + // Show StreamingBubble with fallback: use lastStreamRef while the effect hasn't captured yet + const showStream = chat.streamingText || lastStreamRef.current; + return ( - +
+
+ {accRef.current.map((msg, i) => ( +
+ {}} /> +
+ ))} + {showStream && ( +
+ +
+ )} +
+
+
+ {phase === 'running' ? ( + + ) : ( + + + Task complete + + )} +
+
); }; @@ -68,18 +183,20 @@ type TaskRunnerModalProps = { onOpenChange: (open: boolean) => void; task: TaskSummary; entryName?: string; + entryFullPath?: string; entryType?: 'file' | 'directory'; cwd?: { root?: string; path: string }; promptOverride?: string; }; -export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => { +export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => { const { settings } = useSettings(); const taskSettings = settings.tasks; + const entryRef = entryFullPath ?? entryName; const defaultInput = promptOverride - ?? (entryName && entryType - ? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}` - : `Read the task instructions at ${task.filePath} and execute them`); + ?? (entryRef && entryType + ? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryRef}\n\nBe verbose — explain each step you take and what the result was.` + : `Read the task instructions at ${task.filePath} and execute them\n\nBe verbose — explain each step you take and what the result was.`); const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' }; return ( @@ -102,7 +219,7 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType
- {/* Chat */} + {/* Task Runner */} absPath(entryPath(name)), }; }; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/renderers/CodeRenderer.tsx b/src/workspaces/officerdev/src/apps/FileViewer/renderers/CodeRenderer.tsx index 7b69ea40..741d9175 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/renderers/CodeRenderer.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/renderers/CodeRenderer.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { TextRenderer } from './TextRenderer'; +import { highlight } from './highlight'; type CodeRendererProps = { content: string; @@ -11,14 +12,9 @@ export const CodeRenderer = ({ content, lang }: CodeRendererProps) => { useEffect(() => { let cancelled = false; - import('shiki') - .then(({ codeToHtml }) => codeToHtml(content, { lang, theme: 'github-dark-default' })) - .then((result) => { - if (!cancelled) setHtml(result); - }) - .catch(() => { - if (!cancelled) setHtml(null); - }); + highlight(content, lang).then((result) => { + if (!cancelled) setHtml(result); + }); return () => { cancelled = true; }; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/renderers/MarkdownRenderer.tsx b/src/workspaces/officerdev/src/apps/FileViewer/renderers/MarkdownRenderer.tsx index 730a9b16..bf781c01 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/renderers/MarkdownRenderer.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/renderers/MarkdownRenderer.tsx @@ -3,12 +3,27 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSlug from 'rehype-slug'; +import { highlight } from './highlight'; + +type HastNode = { + type: string; + value?: string; + tagName?: string; + properties?: Record; + children?: HastNode[]; +}; type MarkdownRendererProps = { content: string; scrollContainer: React.RefObject; }; +function getNodeText(node: HastNode): string { + if (node.type === 'text') return node.value ?? ''; + if (node.children) return node.children.map(getNodeText).join(''); + return ''; +} + export const MarkdownRenderer = ({ content, scrollContainer }: MarkdownRendererProps) => { const handleAnchorClick = (ev: React.MouseEvent) => { const target = (ev.target as HTMLElement).closest('a'); @@ -35,23 +50,22 @@ export const MarkdownRenderer = ({ content, scrollContainer }: MarkdownRendererP ); }, - pre({ children }) { - return
{children}
; - }, - code({ className, children, ...props }) { - const isBlock = className?.startsWith('language-'); - const lang = className?.replace('language-', '') ?? ''; - const text = String(children).replace(/\n$/, ''); - - if (!isBlock) { - return ( - - {children} - - ); + pre({ node, children }) { + const codeChild = node?.children[0]; + if (codeChild?.type === 'element' && codeChild.tagName === 'code') { + const classes = (codeChild.properties?.className ?? []) as string[]; + const lang = classes[0]?.replace('language-', '') ?? ''; + const text = getNodeText(codeChild).replace(/\n$/, ''); + return ; } - - return ; + return
{children}
; + }, + code({ children, ...props }) { + return ( + + {children} + + ); }, }} > @@ -84,39 +98,32 @@ const HighlightedCodeBlock = ({ code, lang }: { code: string; lang: string }) => useEffect(() => { let cancelled = false; - import('shiki') - .then(({ codeToHtml }) => codeToHtml(code, { lang, theme: 'github-dark-default' })) - .then((result) => { - if (!cancelled) setHtml(result); - }) - .catch(() => {}); + highlight(code, lang).then((result) => { + if (!cancelled) setHtml(result); + }); return () => { cancelled = true; }; }, [code, lang]); - if (html) { - return ( -
- - {lang && ( - {lang} - )} + return ( +
+ + {lang && ( + + {lang} + + )} + {html ? (
-
- ); - } - - return ( -
-      
-      {lang && (
-        {lang}
+      ) : (
+        
+          {code}
+        
)} - {code} -
+
); }; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/renderers/highlight.ts b/src/workspaces/officerdev/src/apps/FileViewer/renderers/highlight.ts new file mode 100644 index 00000000..21867c1f --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileViewer/renderers/highlight.ts @@ -0,0 +1,25 @@ +import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki'; +import { createHighlighter } from 'shiki'; + +const THEME = 'github-dark-default' as const; + +let instance: Promise> | null = null; + +function getHighlighter() { + if (!instance) { + instance = createHighlighter({ themes: [THEME], langs: [] }); + } + return instance; +} + +export async function highlight(code: string, lang: string): Promise { + try { + const highlighter = await getHighlighter(); + if (lang && !highlighter.getLoadedLanguages().includes(lang)) { + await highlighter.loadLanguage(lang as BundledLanguage); + } + return highlighter.codeToHtml(code, { lang: lang || 'text', theme: THEME }); + } catch { + return null; + } +} diff --git a/src/workspaces/state/src/index.ts b/src/workspaces/state/src/index.ts index 9bdda252..0a3c7f94 100644 --- a/src/workspaces/state/src/index.ts +++ b/src/workspaces/state/src/index.ts @@ -9,8 +9,8 @@ export { useRecentModels } from './useRecentModels'; export { usePlans } from './usePlans'; export { useLandingPage } from './useLandingPage'; export { useServerSettings } from './useServerSettings'; -export { useResources, getResourceCategory } from './useResources'; -export type { Resource, ResourceCredentials, ResourceConnectionConfig, PingResult, ResourceCategory } from './useResources'; +export { useResources } from './useResources'; +export type { ResourceSummary, ResourceDetail, PingResult } from './useResources'; export { useChatSessions } from './useChatSessions'; export type { UseChatSessionsType } from './useChatSessions'; export { useChatGroups } from './useChatGroups'; diff --git a/src/workspaces/state/src/useResources.ts b/src/workspaces/state/src/useResources.ts index 05d58f37..230c8476 100644 --- a/src/workspaces/state/src/useResources.ts +++ b/src/workspaces/state/src/useResources.ts @@ -1,32 +1,21 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; -export type ResourceCredentials = { - apiKey?: string; - username?: string; - password?: string; -}; - -export type ResourceConnectionConfig = { - url: string; - credentials?: ResourceCredentials; -}; - -export type Resource = { - id: string; +export type ResourceSummary = { + dirName: string; name: string; - subtitle: string; - type: string; - port: string | null; description: string; - installCommand: string | null; - uninstallCommand: string | null; - manageCommand: string | null; - verifyCommand: string | null; - updateCommand: string | null; - installed: boolean; - version: string | null; - connectionConfig: ResourceConnectionConfig | null; + scope: 'native' | 'global'; + config: Record; +}; + +export type ResourceDetail = ResourceSummary & { + body: string; + rawFrontmatter: string; + filePath: string; + configPath: string; + chatSessionId: string | null; + guidePath: string; }; export type PingResult = { @@ -34,10 +23,6 @@ export type PingResult = { latencyMs: number | null; }; -export type ResourceCategory = 'api-based' | 'local-cli'; - -export const getResourceCategory = (r: Resource): ResourceCategory => (r.port ? 'api-based' : 'local-cli'); - const RESOURCES_KEY = ['RESOURCES']; export const useResources = () => { @@ -46,23 +31,31 @@ export const useResources = () => { const { data: resources, isLoading } = useQuery({ queryKey: RESOURCES_KEY, - queryFn: () => client.get('/server-settings/resources'), + queryFn: () => client.get('/server-settings/resources'), }); - const saveConnectionConfig = async (id: string, config: Partial) => { - await client.patch(`/server-settings/resources/config/${id}`, config); + const getDetail = (name: string) => + client.get(`/server-settings/resources/${name}`); + + const saveConfig = async (name: string, config: Record) => { + await client.patch(`/server-settings/resources/${name}/config`, config); queryClient.invalidateQueries({ queryKey: RESOURCES_KEY }); }; - const pingResource = async (id: string, url?: string) => { - return client.post(`/server-settings/resources/${id}/ping`, { url }); - }; - - const runCommand = async (id: string, action: string) => { - const result = await client.post<{ exitCode: number; output: string }>(`/server-settings/resources/${id}/run`, { action }); + 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; }; - return { resources, isLoading, saveConnectionConfig, pingResource, runCommand }; + 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(`/server-settings/resources/${name}/ping`, { url }); + }; + + return { resources, isLoading, getDetail, saveConfig, createResource, deleteResource, pingResource }; };