From 2126f3912e54f8fd1eb977d091e2133918316a2c Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 23 Feb 2026 22:52:27 +0000 Subject: [PATCH] pi sills --- AGENTS.md | 189 ++++++++---- seed/skills/mlxaudio/SKILL.md | 2 +- .../{whisper.cpp => whisper-cpp}/SKILL.md | 2 +- .../chat/messages.json | 0 .../chat/meta.json | 0 src/apps/officer-web/App.tsx | 1 + .../Authentication/LandingPage/Login.tsx | 2 +- .../Screens/Dashboard/ChatHistory/index.tsx | 1 + .../Screens/Dashboard/Files/FilesScreen.tsx | 2 +- .../Screens/Dashboard/Home/HomeScreen.tsx | 29 +- .../Dashboard/Layout/Header/UserMenu.tsx | 8 +- .../Dashboard/Projects/ProjectListScreen.tsx | 1 + .../IntegrationsSettings/GoogleAccount.tsx | 109 +++++++ .../GoogleOAuthConfig.tsx | 288 ++++++++++++++++++ .../Settings/IntegrationsSettings/index.tsx | 90 ++++++ .../Dashboard/Settings/SettingsPanel.tsx | 15 +- .../Screens/Dashboard/Settings/index.tsx | 1 + .../Dashboard/Terminal/TerminalScreen.tsx | 2 +- .../Dashboard/Workspaces/WorkspacesScreen.tsx | 1 + src/servers/api/dock/dock.ts | 7 +- src/servers/api/integrations/integrations.ts | 218 +++++++++++++ src/servers/api/pi/pi-bridge.ts | 32 +- src/servers/api/pi/websocket.ts | 38 +-- .../api/server-settings/server-settings.ts | 11 +- src/servers/api/sessions/sessions.ts | 13 +- src/servers/api/settings/settings.ts | 16 +- src/servers/api/workspaces/utils.ts | 13 +- src/servers/bootstrap.ts | 2 + src/servers/hono.ts | 3 + src/servers/sync-skills.ts | 32 ++ .../FileViewer/renderers/VideoRenderer.tsx | 32 +- .../src/components/Workspace/PanelSlot.tsx | 77 ++++- .../Workspace/WorkspaceRenderer.tsx | 9 + .../components/Workspace/WorkspaceView.tsx | 10 +- src/workspaces/state/src/useSettings.ts | 7 + 35 files changed, 1126 insertions(+), 137 deletions(-) rename seed/skills/{whisper.cpp => whisper-cpp}/SKILL.md (99%) rename seed/skills/{whisper.cpp => whisper-cpp}/chat/messages.json (100%) rename seed/skills/{whisper.cpp => whisper-cpp}/chat/meta.json (100%) create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx create mode 100644 src/servers/api/integrations/integrations.ts create mode 100644 src/servers/sync-skills.ts diff --git a/AGENTS.md b/AGENTS.md index 493e2605..ccfff084 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,26 +2,50 @@ Guide for agentic coding assistants working in the Officer monorepo. +## What Is Officer + +Officer is an **AI-powered intranet server** for small and medium businesses. It's a self-hosted platform that gives each team member a personal AI assistant, file storage, terminal, code editor, workspaces, and project management — all under centralized admin control. + +**Think of it as**: a self-hosted, AI-native company intranet where every employee gets their own workspace with shared organizational resources and automation. + +### Multi-User Architecture + +- **Role hierarchy**: Member → Admin → Owner → Super Admin +- **Bootstrap flow**: First user registers as Super Admin, then invites the team +- **Per-user isolation**: Files, sessions, settings, workspaces, and tasks are scoped per user under `$DATA_PATH/{email}/` +- **Shared org resources**: Global tasks/skills/processes, server-level settings (SMTP, AI providers, TTS/STT/OCR), pluggable applications and resources +- **Multi-scope resolution**: Tasks, skills, and processes resolve user → global → native (built-in), enabling org-wide shared automation + +### Core Capabilities + +1. **AI Chat** — Multi-provider (Claude, OpenCode, Pi-Mono) with sessions, attachments, speech-to-text, slash commands +2. **File Browser** — Full filesystem access per user (upload, mkdir, copy, move, delete) +3. **File Viewer** — Preview video, images, code, text, markdown +4. **Terminal** — WebSocket-based PTY terminal with Docker sandboxing +5. **Code Editor** — Monaco-based IDE with file tabs +6. **Projects** — Project management with per-project workspace layouts, git init +7. **Workspaces** — Customizable panel-based layouts (split, resize, swap, drag) +8. **Automation/Skills/Tasks/Processes** — Markdown-based capability definitions with YAML frontmatter +9. **Dev Server** — Start/stop project dev servers with auto-port discovery and live proxy +10. **Dashboard Widgets** — Clock, weather, pomodoro, daily goals, quick notes +11. **Settings** — User preferences, server config, resource management + ## Quick Start Commands ### Development ```bash bun dev # Dashboard + API server (port 5000) -bun dev:tracking # Tracking server (port 5001) -bun dev:experiments # Experiments server -bun dev:emailer # Emailer workspace +bun dev:emailer # Emailer workspace ``` -### Building & Database +### Building ```bash bun run prebuild # Run prebuild tasks +bun run build:web # Build web app bun run build:dashboard # Build dashboard bun run build:editor # Build editor (app + extension + runtime) -bun run build:runtime # Build all runtime scripts -bun run db:gen && db:push # Generate & push officer_db migrations -bun run db:gen:stats && db:push:stats # Generate & push statistics_db ``` ### Code Quality @@ -29,11 +53,82 @@ bun run db:gen:stats && db:push:stats # Generate & push statistics_db ```bash bun format # Format all files (Prettier, required before commit) bun format:check # Check formatting without writing -bunx tsgo # TypeScript type checking (comprehensive, slow) +bunx tsgo # TypeScript type checking ``` **Note:** No automated tests configured yet. Always run `bun format` before committing. +## Project Structure + +``` +src/ +├── apps/ +│ └── officer-web/ # Main web UI (React 19) +│ ├── Screens/ +│ │ ├── Authentication/ # Login, verify, reset password +│ │ └── Dashboard/ # All main screens (Home, Files, Chat, Terminal, Projects, etc.) +│ ├── state/ # App-specific state hooks +│ ├── lib/ # Utilities +│ └── locales/ # i18n translations +│ +├── servers/ +│ ├── api/ # REST API (Hono, port 5000) +│ │ ├── auth/ # Authentication (JWT + WebAuthn passkeys) +│ │ ├── users/ # User management (invite, CRUD) +│ │ ├── sessions/ # Multi-provider chat sessions +│ │ ├── workspaces/ # Workspace & project state +│ │ ├── tasks/ # Task definitions (CRUD + chat) +│ │ ├── skills/ # Skill definitions (CRUD + chat) +│ │ ├── processes/ # Process definitions (CRUD + chat) +│ │ ├── file-browser/ # Filesystem access (multi-root) +│ │ ├── terminal/ # WebSocket PTY terminal +│ │ ├── dev-server/ # Project dev server management +│ │ ├── pi/ # AI agent integration +│ │ ├── scrape/ # Web scraping (Playwright) +│ │ ├── upload/ # File uploads +│ │ ├── settings/ # User settings & state +│ │ ├── server-settings/ # Server-wide config (SMTP, AI, TTS, etc.) +│ │ ├── dock/ # Dock configuration +│ │ ├── plans/ # Markdown plans +│ │ ├── task-logs/ # Task execution logs +│ │ └── landing-page-data/# Registration status +│ └── _middlewares/ # Auth, rate limiting, CORS, body parsing +│ +├── databases/ +│ └── officer_db/ # JSON file-based auth store (users, passkeys, tokens) +│ +└── workspaces/ # 13 shared packages + ├── types/ # Central type re-exports + ├── definitions/ # Constants, enums (roles, statuses, devices) + ├── config/ # URL configs, env vars + ├── helpers/ # cn(), formatters, slug, debounce, queue + ├── hooks/ # 90+ hooks (useClient, useForm, useAuth, etc.) + ├── state/ # React Query state hooks (useSettings, useChatSessions, etc.) + ├── components/ # 89 components (shadcn/ui base + custom) + ├── officerdev/ # Core workspace/panel framework + 11 built-in apps + ├── i18n/ # Internationalization + ├── injector/ # DOM manipulation for visual editing + ├── widgets/ # Dashboard widgets (clock, weather, pomodoro, etc.) + ├── emailer/ # React-email templates + SMTP + └── sounds/ # Audio feedback library +``` + +## Path Aliases + +- `@/` → `src/apps/officer-web/` +- `@@/` → `src/servers/` +- `@/components/*` → `src/workspaces/components/*` + +## Tech Stack + +- **Runtime**: Bun +- **Language**: TypeScript 5.9 (strict mode, verbatimModuleSyntax) +- **Frontend**: React 19, React Router, React Query, Tailwind CSS, shadcn/ui +- **Backend**: Hono framework, JWT auth, WebAuthn passkeys +- **Storage**: JSON file-based (auth store + user data), no traditional DB for most data +- **Build**: Vite +- **AI**: Claude Agent SDK, multi-provider support + ## Code Style Guide ### Imports @@ -48,9 +143,15 @@ import { useExperiment } from 'hooks/use-experiment'; import { formatDate } from '../helpers'; ``` +Type-only imports required (verbatimModuleSyntax): +```ts +import { ActionModals, type ActionModalsTypes } from './ActionModals'; +import type { FormEvent } from 'react'; +``` + ### TypeScript -- Strict mode always - no `any` +- Strict mode always — no `any` - Prefer `type` over `interface` - Colocate prop types with components as named exports - Early returns for null/undefined guards @@ -61,12 +162,11 @@ import { formatDate } from '../helpers'; - Arrow functions for simple/one-liners - Regular functions for complex multi-line logic - Named exports only (never default exports) +- Extract params type when signature gets long (no multiline params) ```ts export const formatDate = (ts: number) => new Date(ts).toLocaleDateString(); -export function calculateStats(data: DataPoint[]) { - /* ... */ -} +export function calculateStats(data: DataPoint[]) { /* ... */ } ``` ### React Components @@ -82,23 +182,13 @@ export const Card = ({ title, onClick }: CardProps) =>
{t ### State Management -- **Manager pattern** for complex hooks - return object with state + methods -- **Colocation** - all feature state in one hook -- **Derived state** - compute in hook, not in components - -```ts -export const useExperimentManager = (id: number) => { - const [exp, setExp] = useState(null); - const isActive = exp?.status === 'running'; - return { - exp, - isActive, - update: (data) => { - /* ... */ - }, - }; -}; -``` +- **React Query** for server state +- **useGlobal()** for UI state (backed by query cache, no Context needed) +- **useWorkspacesState()** for persistent workspace layouts (server-synced) +- **useQueryState()** for URL-synced state +- **usePanelChannel()** for inter-panel pub/sub communication +- **Manager pattern** for complex hooks — return object with state + methods +- **Derived state** — compute in hook, not in components ### Naming Conventions @@ -118,40 +208,13 @@ export const useExperimentManager = (id: number) => { - Use try/catch for async operations - Include context in error messages (IDs, resource names) -- Log with `console.error` and re-throw appropriately -- Handle database connection errors explicitly - -### Async/Await - -- Always use async/await (never .then() chains) -- Minimize nesting - -## Project Structure - -- `src/apps/dashboard/` - Admin UI (React 19) -- `src/apps/editor/` - Visual editor -- `src/servers/api/` - REST API (Hono, port 5000) -- `src/servers/tracking/` - Event collection (port 5001) -- `src/databases/` - PostgreSQL schemas (3 DBs: officer_db, statistics_db, ephemeral_db) -- `src/workspaces/` - Shared: components, hooks, helpers, types - -## Path Aliases - -- `@/` → `src/apps/dashboard/` -- `@@/` → `src/servers/` - -## Tech Stack - -- **Runtime**: Bun -- **Language**: TypeScript 5.9 (strict mode) -- **Frontend**: React 19, React Router, React Query, Tailwind CSS, shadcn/ui -- **Backend**: Hono, PostgreSQL, Drizzle ORM -- **Build**: Vite +- Always async/await (never .then() chains) ## General Guidelines -- **Database-first** approach - schema → API → UI -- **Self-documenting code** - clear naming, minimal comments -- **Explicit over implicit** - no magic -- **Workspace dependencies** - use `workspace:*` -- **Environment variables** - use `.env`, access directly in code +- **Database-first** approach — schema → API → UI +- **Self-documenting code** — clear naming, minimal comments +- **Explicit over implicit** — no magic +- **Workspace dependencies** — use `workspace:*` +- **Multi-user aware** — always consider user isolation and role-based access when adding features +- **File-based storage** — user data lives under `$DATA_PATH/{email}/`, respect the per-user boundary diff --git a/seed/skills/mlxaudio/SKILL.md b/seed/skills/mlxaudio/SKILL.md index 5f10cecb..b07c34c9 100644 --- a/seed/skills/mlxaudio/SKILL.md +++ b/seed/skills/mlxaudio/SKILL.md @@ -1,5 +1,5 @@ --- -name: mlx.audio +name: mlxaudio description: Generate speech from text and transcribe audio using mlx-audio. Use when the user wants text-to-speech synthesis, speech-to-text transcription, voice cloning, audio separation, or speech-to-speech processing on Apple Silicon. --- diff --git a/seed/skills/whisper.cpp/SKILL.md b/seed/skills/whisper-cpp/SKILL.md similarity index 99% rename from seed/skills/whisper.cpp/SKILL.md rename to seed/skills/whisper-cpp/SKILL.md index eb9f7aac..bbb83dfe 100644 --- a/seed/skills/whisper.cpp/SKILL.md +++ b/seed/skills/whisper-cpp/SKILL.md @@ -1,5 +1,5 @@ --- -name: whisper.cpp +name: whisper-cpp description: Transcribe audio files to text using whisper.cpp. Use when the user wants to transcribe audio, convert speech to text, or extract text from an audio/video file. --- diff --git a/seed/skills/whisper.cpp/chat/messages.json b/seed/skills/whisper-cpp/chat/messages.json similarity index 100% rename from seed/skills/whisper.cpp/chat/messages.json rename to seed/skills/whisper-cpp/chat/messages.json diff --git a/seed/skills/whisper.cpp/chat/meta.json b/seed/skills/whisper-cpp/chat/meta.json similarity index 100% rename from seed/skills/whisper.cpp/chat/meta.json rename to seed/skills/whisper-cpp/chat/meta.json diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 4364702c..9eeebbf6 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -40,6 +40,7 @@ export function App() { : } /> : } /> : } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx index 81cd9405..0982e907 100644 --- a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx +++ b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx @@ -11,7 +11,7 @@ import { useGlobal } from 'hooks/useGlobal'; const initialState: LoginFormState = { // email: 'pastilhas@pastilhas.dev', - // password: '1234567890', + // password: '', }; export function Login() { const [isSubmitting, setIsSubmitting] = useState(false); diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 1b8d322b..875ed902 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -70,6 +70,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
{ if (!id) navigate('/chat', { replace: true }); diff --git a/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx index e72ea7c6..28dfaf85 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx @@ -9,7 +9,7 @@ export const FilesScreen = () => { return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx index 3e771782..1ce94d18 100644 --- a/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx @@ -1,9 +1,17 @@ import type { LayoutNode, PanelComponents, DefaultFileSort } from 'officerdev'; import { WorkspaceView, useFileViewerPanels } from 'officerdev'; import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useSettings } from 'state/useSettings'; +import { Button } from '@/components/ui/button'; import { defaultLayout } from './defaultLayout'; const HomeHeader = () => { + const { settings, saveSettings } = useSettings(); + + const completeOnboarding = () => { + saveSettings({ ...settings, onboarding: { complete: true } }); + }; + return (
@@ -11,6 +19,9 @@ const HomeHeader = () => {

Please follow the video instructions below in order to get familiar with all that is possible.

+
); @@ -25,10 +36,26 @@ const defaultSort: DefaultFileSort = { field: 'type', direction: 'desc' }; export const HomeScreen = () => { const workspace = useWorkspacesState('screens/home', defaultLayout); const ephemeral = useFileViewerPanels(); + const { settings } = useSettings(); + + if (settings.onboarding.complete) { + return ( +
+ +
+ ); + } return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx index 9588006a..dba42ca4 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router'; import * as Dropdown from '@/components/ui/dropdown-menu'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { User, Users, LogOut, Settings, Package, Sun, Moon } from 'lucide-react'; +import { User, Users, LogOut, Settings, Package, Puzzle, Sun, Moon } from 'lucide-react'; import { useAuth } from 'hooks/useAuth'; import { useTranslation } from '@/lib/i18n'; import { useColorMode } from '@/components/ui/ThemeProvider'; @@ -59,6 +59,12 @@ export function UserMenu() { )} + + + + Integrations + + {user?.role === 'Super Admin' && ( diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx index eb771730..13b986ff 100644 --- a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx @@ -30,6 +30,7 @@ export const ProjectListScreen = () => {
{ if (!id) setSelected(null); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx new file mode 100644 index 00000000..d7e2f49d --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx @@ -0,0 +1,109 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { useClient } from 'hooks/useClient'; + +type GoogleStatus = { + connected: boolean; + email: 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 fetchStatus = () => { + client + .get('/integrations/google/status') + .then(setStatus) + .catch(() => {}) + .finally(() => setIsLoading(false)); + }; + + useEffect(() => { + fetchStatus(); + const params = new URLSearchParams(window.location.search); + const result = params.get('google'); + if (result === 'success') { + toast.success('Google account connected'); + } else if (result === 'error') { + toast.error('Failed to connect Google account'); + } + if (result) { + window.history.replaceState({}, '', window.location.pathname); + } + }, []); + + const handleConnect = () => { + const params = new URLSearchParams({ + token: client.token ?? '', + origin: window.location.origin, + }); + window.location.href = `/api/integrations/google/authorize?${params.toString()}`; + }; + + const handleDisconnect = async () => { + try { + await client.delete('/integrations/google/connection'); + setStatus({ ...status, connected: false, email: null }); + toast.success('Google account disconnected'); + } catch { + toast.error('Failed to disconnect Google account'); + } + }; + + if (isLoading) return null; + + if (!status.configured) { + return ( +
+

+ Google integration has not been configured yet. Ask your administrator to set up Google OAuth credentials in + the Enterprise settings. +

+
+ ); + } + + if (status.connected) { + return ( +
+
+
+
+

Connected

+

{status.email}

+
+
+

+ Officer has access to your Google Calendar, Gmail, and other enabled services. +

+ +
+ ); + } + + return ( +
+

+ Connect your Google account to give Officer access to your Calendar, Gmail, and other Google services. +

+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx new file mode 100644 index 00000000..cf590a7b --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx @@ -0,0 +1,288 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { ChevronDown } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; +import { useClient } from 'hooks/useClient'; + +type GoogleOAuthSettings = { + clientId: string; + clientSecret: string; +}; + +const SCOPES = [ + { scope: 'gmail.readonly', description: 'Read emails' }, + { scope: 'calendar.readonly', description: 'Read calendar events' }, +]; + +const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { + const [open, setOpen] = useState(false); + + return ( + + + + Step-by-step setup guide + + +
    +
  1. + Create a Google Cloud project +

    + Go to the{' '} + + New Project + {' '} + page. Give it a name (e.g. "Officer") and click Create. +

    +
  2. + +
  3. + Enable the APIs +

    + Go to{' '} + + API Library + + . Search for and enable each of these: +

    +
      +
    • Gmail API
    • +
    • Google Calendar API
    • +
    +

    Click each one, then click Enable.

    +
  4. + +
  5. + Configure the OAuth consent screen +

    + Go to{' '} + + OAuth Branding + + . +

    +
      +
    • Set App name to your organization name or "Officer"
    • +
    • Set User support email to your admin email
    • +
    • Add your admin email under Developer contact information
    • +
    • Click Save
    • +
    +
  6. + +
  7. + Set the audience +

    + Go to{' '} + + OAuth Audience + + . +

    +
      +
    • + If your team uses Google Workspace, select Internal — no verification needed +
    • +
    • + Otherwise, select External and add your team's emails under Test users (required while the app is unverified; limit of 100 test users) +
    • +
    +
  8. + +
  9. + Add scopes +

    + In the left sidebar, click{' '} + + Data Access + + , then click Add or remove scopes. Search for and add: +

    +
      + {SCOPES.map((s) => ( +
    • + + {s.scope} + {' '} + — {s.description} +
    • + ))} +
    +

    Click Update, then Save.

    +

    + Note: calendar.readonly is classified as sensitive and{' '} + gmail.readonly as restricted by Google. + This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification. +

    +
  10. + +
  11. + Create OAuth credentials +

    + In the left sidebar, click{' '} + + Clients + + , then click Create OAuth client. +

    +
      +
    • Application type: Web application
    • +
    • Name: anything (e.g. "Officer")
    • +
    • + Authorized redirect URIs: add{' '} + + {redirectUri} + +
    • +
    • Click Create
    • +
    +
  12. + +
  13. + Copy the credentials +

    + A dialog will show your Client ID and Client Secret. Copy both and paste them into the fields below. +

    +
  14. +
+
+
+ ); +}; + +type VerifyStatus = { valid: boolean; error: string | null } | null; + +const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => { + if (isVerifying) { + return ( +
+
+ Verifying credentials... +
+ ); + } + + if (!status) return null; + + return ( +
+
+ + {status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'} + +
+ ); +}; + +export const GoogleOAuthConfig = () => { + const client = useClient(); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [verifyStatus, setVerifyStatus] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + + const verify = () => { + setIsVerifying(true); + client + .get<{ valid: boolean; error: string | null }>('/integrations/google/verify') + .then(setVerifyStatus) + .catch(() => setVerifyStatus({ valid: false, error: 'Verification request failed' })) + .finally(() => setIsVerifying(false)); + }; + + useEffect(() => { + client + .get('/integrations/google/config') + .then((data) => { + if (data) { + setClientId(data.clientId); + setClientSecret(data.clientSecret); + } + }) + .catch(() => {}) + .finally(() => { + setIsLoading(false); + }); + }, []); + + // Verify on load if credentials exist + useEffect(() => { + if (!isLoading && clientId && clientSecret) verify(); + }, [isLoading]); + + const handleSave = async () => { + if (isSaving) return; + setIsSaving(true); + try { + await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() }); + toast.success('Google OAuth configuration saved'); + verify(); + } catch { + toast.error('Failed to save Google OAuth configuration'); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) return null; + + const redirectUri = `${window.location.origin}/api/integrations/google/callback`; + + return ( +
+ + + + +
+ + + + + + + +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx new file mode 100644 index 00000000..53f7e256 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx @@ -0,0 +1,90 @@ +import { useMemo } from 'react'; +import { Puzzle, KeyRound, UserCircle } from 'lucide-react'; +import type { LayoutNode, PanelComponents } from 'officerdev'; +import { WorkspaceLayout } from 'officerdev'; +import { useAuth } from 'hooks/useAuth'; +import { useGlobal } from 'hooks/useGlobal'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel'; +import { GoogleOAuthConfig } from './GoogleOAuthConfig'; +import { GoogleAccount } from './GoogleAccount'; + +const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED'; +const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB'; + +const enterpriseSections: SettingsSection[] = [ + { key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: }, +]; + +const personalSections: SettingsSection[] = [ + { key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: }, +]; + +const IntegrationsSidebar = () => { + const { user } = useAuth(); + const isSuperAdmin = user?.role === 'Super Admin'; + const [tab, setTab] = useGlobal(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal'); + const sections = tab === 'enterprise' ? enterpriseSections : personalSections; + + return ( +
+
+
+ + Integrations +
+
+ {isSuperAdmin && ( +
+ + + + Enterprise + + + Personal + + + +
+ )} + +
+ ); +}; + +const IntegrationsContent = () => { + const { user } = useAuth(); + const isSuperAdmin = user?.role === 'Super Admin'; + const [tab] = useGlobal(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal'); + const sections = tab === 'enterprise' ? enterpriseSections : personalSections; + + return ; +}; + +const layout: LayoutNode = { + type: 'group', + id: 'integrations-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'integrations-left', appType: null }, size: 20 }, + { node: { type: 'panel', id: 'integrations-right', appType: null }, size: 80 }, + ], +}; + +export const IntegrationsSettings = () => { + const panelComponents: PanelComponents = useMemo( + () => ({ + 'integrations-left': IntegrationsSidebar, + 'integrations-right': IntegrationsContent, + }), + [], + ); + + return ( +
+ {}} components={panelComponents} /> +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx index aa9c9474..681423a1 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx @@ -24,6 +24,7 @@ type SettingsSidebarProps = { label: string; sections: SettingsSection[]; groups?: SettingsSectionGroup[]; + hideHeader?: boolean; }; const SectionButton = ({ @@ -50,7 +51,7 @@ const SectionButton = ({ ); -export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups }: SettingsSidebarProps) => { +export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => { const allSections = groups ? groups.flatMap((g) => g.sections) : sections; const [selectedKey, setSelectedKey] = useGlobal(globalKey, allSections[0]?.key ?? null); const [search, setSearch] = useState(''); @@ -61,12 +62,14 @@ export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups return (
-
-
- - {label} + {!hideHeader && ( +
+
+ + {label} +
-
+ )}
setSearch(ev.target.value)} className="h-8 text-xs" />
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx index 19193b65..08a0b741 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx @@ -2,3 +2,4 @@ export * from './ProfileSettings'; export * from './SystemSettings'; export * from './ResourceSettings'; export * from './UserSettings'; +export * from './IntegrationsSettings'; diff --git a/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx index dd38b3de..3489b233 100644 --- a/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx @@ -8,7 +8,7 @@ export const TerminalScreen = () => { return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx index a783c17f..67e2af6d 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx @@ -15,6 +15,7 @@ export const WorkspacesScreen = () => {
{ if (!id) setSelected(null); diff --git a/src/servers/api/dock/dock.ts b/src/servers/api/dock/dock.ts index 5250b730..61e72bb0 100644 --- a/src/servers/api/dock/dock.ts +++ b/src/servers/api/dock/dock.ts @@ -14,8 +14,11 @@ dockRouter.get('/', async (ctx) => { const file = Bun.file(filePath); if (await file.exists()) { - const data = await file.json(); - return ctx.json(data); + try { + return ctx.json(await file.json()); + } catch { + // corrupted file — treat as missing + } } return ctx.json(null); diff --git a/src/servers/api/integrations/integrations.ts b/src/servers/api/integrations/integrations.ts new file mode 100644 index 00000000..cb4899e0 --- /dev/null +++ b/src/servers/api/integrations/integrations.ts @@ -0,0 +1,218 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { createRouter } from '../../create-router'; +import { DATA_PATH } from '@@/data-path'; +import { CustomError } from '../../custom-errors'; + +const configDir = `${homedir()}/.config/officer.dev`; +const googleConfigPath = join(configDir, 'google-oauth.json'); + +const GOOGLE_SCOPES = [ + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/userinfo.email', +]; + +const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true }); + +export const readGoogleConfig = async () => { + try { + return await Bun.file(googleConfigPath).json(); + } catch { + return null; + } +}; + +const getUserGoogleFile = (email: string) => join(DATA_PATH, email, 'integrations', 'google.json'); + +const readUserGoogle = async (email: string) => { + try { + return await Bun.file(getUserGoogleFile(email)).json(); + } catch { + return null; + } +}; + +const writeUserGoogle = async (email: string, data: Record) => { + const filePath = getUserGoogleFile(email); + await ensureDir(filePath); + await Bun.write(filePath, JSON.stringify(data, null, 2)); +}; + +export const integrationsRouter = createRouter(); + +integrationsRouter.get('/', async (ctx) => { + return ctx.json([]); +}); + +// --- Enterprise: Google OAuth config (Super Admin only) --- + +integrationsRouter.get('/google/config', async (ctx) => { + const user = ctx.get('user'); + if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403); + return ctx.json(await readGoogleConfig()); +}); + +integrationsRouter.put('/google/config', async (ctx) => { + const user = ctx.get('user'); + if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403); + + const body = ctx.get('body') as { clientId?: string; clientSecret?: string }; + const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' }; + + await ensureDir(googleConfigPath); + await Bun.write(googleConfigPath, JSON.stringify(config, null, 2)); + return ctx.json(config); +}); + +integrationsRouter.get('/google/verify', async (ctx) => { + const user = ctx.get('user'); + if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403); + + const config = await readGoogleConfig(); + if (!config?.clientId || !config?.clientSecret) { + return ctx.json({ valid: false, error: 'Missing credentials' }); + } + + // Send a dummy token exchange — valid credentials return "invalid_grant", + // invalid credentials return "invalid_client" + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code: 'invalid_code', + redirect_uri: 'https://localhost', + grant_type: 'authorization_code', + }), + }); + + const body = await res.json(); + const valid = body.error === 'invalid_grant' || body.error === 'redirect_uri_mismatch'; + + return ctx.json({ valid, error: valid ? null : body.error_description ?? body.error }); +}); + +// --- Personal: Google account connection status --- + +integrationsRouter.get('/google/status', async (ctx) => { + const email = ctx.get('user').email; + const config = await readGoogleConfig(); + const connection = await readUserGoogle(email); + + return ctx.json({ + configured: !!(config?.clientId && config?.clientSecret), + connected: !!connection?.accessToken, + email: connection?.email ?? null, + }); +}); + +integrationsRouter.delete('/google/connection', async (ctx) => { + const email = ctx.get('user').email; + const filePath = getUserGoogleFile(email); + + const file = Bun.file(filePath); + if (await file.exists()) { + await Bun.write(filePath, '{}'); + } + + return ctx.json({ ok: true }); +}); + +// --- OAuth flow: authorize (protected — user must be logged in) --- + +integrationsRouter.get('/google/authorize', async (ctx) => { + const config = await readGoogleConfig(); + if (!config?.clientId || !config?.clientSecret) { + throw new CustomError('Google OAuth not configured', 400); + } + + const email = ctx.get('user').email; + const origin = ctx.req.query('origin'); + if (!origin) throw new CustomError('Missing origin parameter', 400); + const redirectUri = `${origin}/api/integrations/google/callback`; + + const state = Buffer.from(JSON.stringify({ email, redirectUri })).toString('base64url'); + + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: GOOGLE_SCOPES.join(' '), + access_type: 'offline', + prompt: 'consent', + state, + }); + + return ctx.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`); +}); + +// --- OAuth callback (public — called by Google, exported for hono.ts) --- + +export const googleCallbackHandler = async (ctx: any) => { + const code = ctx.req.query('code'); + const stateParam = ctx.req.query('state'); + const error = ctx.req.query('error'); + + if (error || !code || !stateParam) { + return ctx.redirect('/settings/integrations?google=error'); + } + + let email: string; + let redirectUri: string; + try { + const parsed = JSON.parse(Buffer.from(stateParam, 'base64url').toString()); + email = parsed.email; + redirectUri = parsed.redirectUri; + } catch { + return ctx.redirect('/settings/integrations?google=error'); + } + + const config = await readGoogleConfig(); + if (!config?.clientId || !config?.clientSecret) { + return ctx.redirect('/settings/integrations?google=error'); + } + + // Exchange code for tokens + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!tokenResponse.ok) { + console.error('Google token exchange failed:', await tokenResponse.text()); + return ctx.redirect('/settings/integrations?google=error'); + } + + const tokens = await tokenResponse.json(); + + // Fetch the user's Google email + const userinfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + + let googleEmail = email; + if (userinfoResponse.ok) { + const userinfo = await userinfoResponse.json(); + googleEmail = userinfo.email ?? email; + } + + await writeUserGoogle(email, { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + tokens.expires_in * 1000, + email: googleEmail, + scope: tokens.scope, + }); + + return ctx.redirect('/settings/integrations?google=success'); +}; diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index f5498d37..cbb10b17 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -1,13 +1,33 @@ import { join, relative } from "path"; +import { readdirSync, existsSync, mkdirSync } from "node:fs"; import type { Subprocess } from "bun"; import type { PiEvent, MessageCost } from "./types"; import { readApiKeys } from "../server-settings/pi-mono"; -import { PI_CONFIG_DIR } from "../../data-path"; +import { PI_CONFIG_DIR, getGlobalSkillsDir, getUserSkillsDir } from "../../data-path"; import { ensureDockerContainer } from "../terminal/websocket"; import { logger } from "./logger"; export type PiEventHandler = (event: PiEvent) => void; +function collectSkillFlags(email: string): string[] { + const flags: string[] = []; + const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)]; + + for (const dir of dirs) { + if (!existsSync(dir)) continue; + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillFile = join(dir, entry.name, 'SKILL.md'); + if (existsSync(skillFile)) { + flags.push('--skill', join(dir, entry.name)); + } + } + } + + return flags; +} + type SandboxOptions = { userId: number; username: string; @@ -18,6 +38,7 @@ type SandboxOptions = { export async function spawnPi( cwd: string, model: string, + email: string, onEvent: PiEventHandler, sandbox?: SandboxOptions, ): Promise { @@ -59,9 +80,14 @@ export async function spawnPi( logger.info('Spawned Pi in container', { containerId, model }); } else { const storedKeys = await readApiKeys(); - const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes']; + const skillFlags = collectSkillFlags(email); + const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags]; if (model) args.push('--model', model); + if (!existsSync(cwd)) { + mkdirSync(cwd, { recursive: true }); + } + proc = Bun.spawn(args, { cwd, stdin: 'pipe', @@ -69,6 +95,8 @@ export async function spawnPi( stderr: 'pipe', env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR }, }); + + logger.info('Spawned Pi locally', { model, skills: skillFlags.filter((f) => f !== '--skill').length }); } // Read stdout JSON event stream (runs in background) diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 72653a54..6eb36cf1 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -36,23 +36,25 @@ type WSData = { const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour -const resolveRoot = (email: string, root?: string) => { - if (!root || root === 'home') return getHomeDir(email); - if (root === '~') return homedir(); - if (root === 'officer.dev') return resolve(process.cwd(), '..'); - return getHomeDir(email); +const resolveSandboxedCwd = (email: string, cwdRoot?: string, cwd?: string) => { + const root = !cwdRoot || cwdRoot === 'home' ? getHomeDir(email) : getHomeDir(email); + if (!cwd || cwd === '~') return root; + if (cwd.startsWith('~/')) return join(root, cwd.slice(2)); + if (cwd.startsWith('/')) return join(root, cwd.slice(1)); + return root; }; -const resolveCwd = (home: string, cwd?: string) => { - if (!cwd || cwd === '~') return home; - if (cwd.startsWith('~/')) return join(home, cwd.slice(2)); - if (cwd.startsWith('/')) return join(home, cwd.slice(1)); - return home; +const resolveHostCwd = (cwdRoot?: string, cwd?: string) => { + if (cwdRoot === 'officer.dev') return resolve(process.cwd(), '..'); + const root = homedir(); + if (!cwd || cwd === '~') return root; + if (cwd.startsWith('/')) return cwd; + if (cwd.startsWith('~/')) return join(root, cwd.slice(2)); + return join(root, cwd); }; export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => { - const root = resolveRoot(email, cwdRoot); - return resolveCwd(root, cwd); + return resolveHostCwd(cwdRoot, cwd); }; const wsToSessionMap = new WeakMap(); @@ -271,11 +273,11 @@ async function handleChat( }); const homeDir = getHomeDir(email); - const rootDir = resolveRoot(email, msg.cwdRoot); - const cwd = resolveCwd(rootDir, msg.cwd); - const groupSlug = msg.groupSlug || null; - const sandboxed = msg.sandboxed ?? false; + const cwd = sandboxed + ? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd) + : resolveHostCwd(msg.cwdRoot, msg.cwd); + const groupSlug = msg.groupSlug || null; const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug); session.sandboxed = sandboxed; session.userId = userId; @@ -286,7 +288,7 @@ async function handleChat( if (!session.piProcess) { try { const onEvent = createEventHandler(sessionId, model, cwd, homeDir); - session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined); + session.piProcess = await piBridge.spawnPi(cwd, model, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined); logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed }); } catch (err) { logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) }); @@ -355,7 +357,7 @@ async function handleResume( const homeDir = getHomeDir(email); const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined; const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir); - session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox); + session.piProcess = await piBridge.spawnPi(session.cwd, session.model, email, onEvent, sandbox); logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed }); } catch (err) { logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) }); diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index 2e1b0256..6d2be075 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -34,19 +34,22 @@ serverSettingsRouter.route('/tts', ttsRouter); serverSettingsRouter.route('/stt', sttRouter); serverSettingsRouter.route('/ocr', ocrRouter); +const readSettings = async () => { + try { return await Bun.file(settingsPath).json(); } catch { return {}; } +}; + serverSettingsRouter.get('/settings', async (ctx) => { - const settings = await Bun.file(settingsPath).json(); - return ctx.json(settings); + return ctx.json(await readSettings()); }); serverSettingsRouter.get('/onboarding-complete', async (ctx) => { - const settings = await Bun.file(settingsPath).json(); + const settings = await readSettings(); return ctx.json({ onboardingComplete: !!settings.onboardingComplete }); }); serverSettingsRouter.put('/', async (ctx) => { const body = await ctx.req.json(); - const settings = await Bun.file(settingsPath).json(); + const settings = await readSettings(); const updated = { ...settings, ...body }; await Bun.write(settingsPath, JSON.stringify(updated, null, 2)); return ctx.json(updated); diff --git a/src/servers/api/sessions/sessions.ts b/src/servers/api/sessions/sessions.ts index adc6b783..8a69bacc 100644 --- a/src/servers/api/sessions/sessions.ts +++ b/src/servers/api/sessions/sessions.ts @@ -34,7 +34,7 @@ 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([]); - return ctx.json(await file.json()); + try { return ctx.json(await file.json()); } catch { return ctx.json([]); } } if (provider === 'opencode') { @@ -44,7 +44,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => { if (provider === 'pi-mono') { const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json')); if (!(await file.exists())) return ctx.json([]); - return ctx.json(await file.json()); + try { return ctx.json(await file.json()); } catch { return ctx.json([]); } } return ctx.json({ error: 'invalid provider' }, 400); @@ -78,7 +78,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => { const dir = getSessionDir(email, id); const metaFile = Bun.file(join(dir, 'meta.json')); if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404); - const meta = await metaFile.json(); + let meta: Record; + 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 }); @@ -88,7 +89,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => { 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); - const meta = await metaFile.json(); + let meta: Record; + 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)); @@ -106,7 +108,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => { 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); - const meta = await metaFile.json(); + let meta: Record; + 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 }); diff --git a/src/servers/api/settings/settings.ts b/src/servers/api/settings/settings.ts index 45495b43..e65e867c 100644 --- a/src/servers/api/settings/settings.ts +++ b/src/servers/api/settings/settings.ts @@ -27,8 +27,11 @@ settingsRouter.get('/settings', async (ctx) => { const file = Bun.file(filePath); if (await file.exists()) { - const data = await file.json(); - return ctx.json(data); + try { + return ctx.json(await file.json()); + } catch { + // corrupted — fall through to defaults + } } await ensureDir(filePath); @@ -54,8 +57,11 @@ settingsRouter.get('/state', async (ctx) => { const file = Bun.file(filePath); if (await file.exists()) { - const data = await file.json(); - return ctx.json(data); + try { + return ctx.json(await file.json()); + } catch { + // corrupted — fall through to empty + } } await ensureDir(filePath); @@ -72,7 +78,7 @@ settingsRouter.patch('/state', async (ctx) => { let existing: Record = {}; if (await file.exists()) { - existing = await file.json(); + try { existing = await file.json(); } catch { /* corrupted — start fresh */ } } const merged = { ...existing, ...body }; diff --git a/src/servers/api/workspaces/utils.ts b/src/servers/api/workspaces/utils.ts index 64d5a7c7..9a8ba01f 100644 --- a/src/servers/api/workspaces/utils.ts +++ b/src/servers/api/workspaces/utils.ts @@ -72,9 +72,13 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null { } export async function readJsonFile(path: string): Promise { - const file = Bun.file(path); - if (await file.exists()) return file.json(); - return null; + try { + const file = Bun.file(path); + if (!(await file.exists())) return null; + return await file.json(); + } catch { + return null; + } } export async function writeJsonFile(path: string, data: unknown) { @@ -86,7 +90,8 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) { const file = Bun.file(stateFile); if (!(await file.exists())) return; - const state = (await file.json()) as Record; + let state: Record; + try { state = (await file.json()) as Record; } catch { return; } const wsKeys = Object.keys(state).filter( (k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'), ); diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index a31292ba..a550f540 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -5,6 +5,7 @@ import { DATA_PATH, PI_CONFIG_DIR } from './data-path'; import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config'; import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config'; import { initAuthStore } from 'officerdb'; +import { syncSeedSkills } from './sync-skills'; mkdirSync(DATA_PATH, { recursive: true }); mkdirSync(PI_CONFIG_DIR, { recursive: true }); @@ -69,6 +70,7 @@ function seedPiConfig(): void { } seedPiConfig(); + syncSeedSkills(); await syncLocalProvidersToPiConfig().catch(err => { console.error('[bootstrap] Failed to sync local providers to Pi config:', err); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index ea6674c0..516e4e77 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -20,6 +20,7 @@ import { router as fileBrowserRouter } from './api/file-browser/router'; import { piRestRouter } from './api/pi/rest'; import { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; import { dockRouter } from './api/dock/dock'; +import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations'; import { CustomError } from './custom-errors'; import { userMiddleware, bodyParser } from './_middlewares'; @@ -42,6 +43,7 @@ honoServer.route('/api/auth', authRouter); honoServer.route('/api/server-settings', serverSettingsRouter); honoServer.route('/api/landing-page-data', landingPageDataRouter); honoServer.route('/api/dev-server-proxy', devServerProxyRouter); +honoServer.get('/api/integrations/google/callback', googleCallbackHandler); const protectedRouter = createRouter(); protectedRouter.use(bodyParser()); @@ -61,6 +63,7 @@ protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/dev-server', devServerRouter); protectedRouter.route('/dock', dockRouter); +protectedRouter.route('/integrations', integrationsRouter); protectedRouter.route('/', piRestRouter); honoServer.route('/api', protectedRouter); diff --git a/src/servers/sync-skills.ts b/src/servers/sync-skills.ts new file mode 100644 index 00000000..273bcbd6 --- /dev/null +++ b/src/servers/sync-skills.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_SKILLS_DIR = join(SEED_PATH, 'skills'); +const GLOBAL_SKILLS_DIR = join(DATA_PATH, 'skills'); + +export function syncSeedSkills(): void { + if (!existsSync(SEED_SKILLS_DIR)) return; + + mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true }); + + const seedEntries = readdirSync(SEED_SKILLS_DIR, { withFileTypes: true }); + + for (const entry of seedEntries) { + if (!entry.isDirectory()) continue; + + const seedSkillDir = join(SEED_SKILLS_DIR, entry.name); + const skillFile = join(seedSkillDir, 'SKILL.md'); + if (!existsSync(skillFile)) continue; + + const targetDir = join(GLOBAL_SKILLS_DIR, entry.name); + + if (existsSync(targetDir)) { + // Skill already exists in DATA_PATH — skip to preserve user edits + continue; + } + + cpSync(seedSkillDir, targetDir, { recursive: true }); + console.log(`[skills] Synced seed skill: ${entry.name}`); + } +} diff --git a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx index be7613a1..dffd4a29 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx @@ -27,6 +27,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps useEffect(() => { const v = videoRef.current; if (!v) return; + let blobUrl: string | null = null; const onLoaded = () => { setDuration(v.duration); setLoaded(true); @@ -35,13 +36,33 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps const onPlay = () => setPlaying(true); const onPause = () => setPlaying(false); const onEnded = () => setPlaying(false); + let fetching = false; + let fetchDone = false; const onError = () => { - if (fallbackSrc && v.src !== fallbackSrc) { - v.src = fallbackSrc; - v.load(); - } else { + if (fetching) return; + if (fetchDone) { setError(true); + return; } + fetching = true; + const fetchUrl = fallbackSrc || src; + fetch(fetchUrl) + .then((res) => { + if (!res.ok) throw new Error(); + return res.blob(); + }) + .then((blob) => { + fetching = false; + fetchDone = true; + blobUrl = URL.createObjectURL(blob); + v.src = blobUrl; + v.load(); + }) + .catch(() => { + fetching = false; + fetchDone = true; + setError(true); + }); }; v.addEventListener('loadedmetadata', onLoaded); v.addEventListener('timeupdate', onTime); @@ -56,6 +77,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps v.removeEventListener('pause', onPause); v.removeEventListener('ended', onEnded); v.removeEventListener('error', onError); + if (blobUrl) URL.revokeObjectURL(blobUrl); }; }, []); @@ -125,7 +147,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps if (playing) setShowControls(false); }} > -
+ ); +}; + +const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children: React.ReactNode }) => { + const { maximizedPanelId, setMaximizedPanelId } = useWorkspace(); + const isMaximized = maximizedPanelId === panelId; + + return ( + + {children} + + setMaximizedPanelId(isMaximized ? null : panelId)}> + {isMaximized ? 'Restore' : 'Maximize'} + + + + ); +}; + // TODO: drag-to-reposition needs work (visual feedback, edge cases) // const DragHandle = ({ panelId }: { panelId: string }) => { // const { setDragSourceId, dragSourceId } = useWorkspace(); @@ -182,7 +223,7 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId // return
; // }; -export const PanelSlot = ({ panel, registry, components, interactive, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => { +export const PanelSlot = ({ panel, registry, components, interactive, locked, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => { const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace(); const isMaximized = maximizedPanelId === panel.id; @@ -199,14 +240,18 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader, const onClose = panelEntry?.onClose; const contextMenu = interactive - ? (content: React.ReactNode) => ( - onSetApp(panel.id, null)}> - {content} - - ) + ? locked + ? (content: React.ReactNode) => ( + {content} + ) + : (content: React.ReactNode) => ( + onSetApp(panel.id, null)}> + {content} + + ) : (content: React.ReactNode) => <>{content}; - const overlays = interactive ? ( + const overlays = interactive && !locked ? ( <> @@ -214,7 +259,7 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader, ) : null; if (!AppComponent) { - if (!interactive) { + if (!interactive || locked) { return (
@@ -254,7 +299,11 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader, const ResolvedHeader = HeaderComponent ?? DefaultHeader; const trafficLights = interactive && !isMobile ? ( - onSetApp(panel.id, null)} /> + locked ? ( + + ) : ( + onSetApp(panel.id, null)} /> + ) ) : null; const mobileBackButton = isMobile && onMobileBack ? ( @@ -285,9 +334,13 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader, ); const headerBar = interactive ? ( - onSetApp(panel.id, null)}> - {headerContent} - + locked ? ( + {headerContent} + ) : ( + onSetApp(panel.id, null)}> + {headerContent} + + ) ) : headerContent; const body = ( diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx index 6b8a88b9..481686aa 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceRenderer.tsx @@ -9,6 +9,7 @@ type WorkspaceRendererProps = { registry: AppRegistry; components?: PanelComponents; interactive?: boolean; + locked?: boolean; noHeader?: boolean; isMobile?: boolean; mobilePanelId?: string; @@ -23,6 +24,7 @@ export const WorkspaceRenderer = ({ registry, components, interactive = false, + locked = false, noHeader = false, isMobile = false, mobilePanelId, @@ -40,6 +42,7 @@ export const WorkspaceRenderer = ({ registry={registry} components={components} interactive={interactive} + locked={locked} noHeader={noHeader} isMobile={isMobile} mobilePanelId={mobilePanelId} @@ -58,6 +61,7 @@ type LayoutNodeRendererProps = { registry: AppRegistry; components?: PanelComponents; interactive: boolean; + locked: boolean; noHeader: boolean; isMobile: boolean; mobilePanelId?: string; @@ -91,6 +95,7 @@ const LayoutNodeRenderer = ({ registry, components, interactive, + locked, noHeader, isMobile, mobilePanelId, @@ -125,6 +130,7 @@ const LayoutNodeRenderer = ({ registry={registry} components={components} interactive={interactive} + locked={locked} noHeader={noHeader} isLastPanel={totalPanels <= 1} onSetApp={onSetApp} @@ -144,6 +150,7 @@ const LayoutNodeRenderer = ({ registry={registry} components={components} interactive={interactive} + locked={locked} noHeader={noHeader} isMobile={isMobile} mobilePanelId={mobilePanelId} @@ -171,6 +178,7 @@ const LayoutNodeRenderer = ({ registry={registry} components={components} interactive={interactive} + locked={locked} noHeader={noHeader} isMobile={isMobile} mobilePanelId={mobilePanelId} @@ -198,6 +206,7 @@ const LayoutNodeRenderer = ({ registry={registry} components={components} interactive={interactive} + locked={locked} noHeader={noHeader} isMobile={isMobile} mobilePanelId={mobilePanelId} diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx index 9df6e169..ebcac4bb 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx @@ -12,6 +12,7 @@ import { useAppRegistry } from '../../AppRegistry/useAppRegistry'; type WorkspaceViewProps = { workspace: WorkspaceState; + locked?: boolean; cwd?: string; root?: string; initialFilePath?: string; @@ -24,7 +25,7 @@ type WorkspaceViewProps = { const noop = () => {}; -export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => { +export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => { const { registry } = useAppRegistry(); const isMobile = useIsMobile(); @@ -178,11 +179,12 @@ export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, def registry={registry} components={components} interactive + locked={locked} isMobile={isMobile} mobilePanelId={mobilePanelId} - onSetApp={handleSetApp} - onSplit={handleSplit} - onRemove={handleRemove} + onSetApp={locked ? noop : handleSetApp} + onSplit={locked ? noop : handleSplit} + onRemove={locked ? noop : handleRemove} onResized={handleResized} /> diff --git a/src/workspaces/state/src/useSettings.ts b/src/workspaces/state/src/useSettings.ts index e0a0f0ee..8ead5133 100644 --- a/src/workspaces/state/src/useSettings.ts +++ b/src/workspaces/state/src/useSettings.ts @@ -15,6 +15,7 @@ const mergeWithDefaults = (saved: Partial): UserSettings => ({ tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks }, appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance }, languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages }, + onboarding: { ...DEFAULT_SETTINGS.onboarding, ...saved.onboarding }, }); export const useSettings = () => { @@ -72,6 +73,9 @@ export type UserSettings = { default: string; translateTo: string; }; + onboarding: { + complete: boolean; + }; }; export type UserState = Record; @@ -103,4 +107,7 @@ export const DEFAULT_SETTINGS: UserSettings = { default: 'en', translateTo: 'en', }, + onboarding: { + complete: false, + }, };