claude-code streaming chat, desktop remote viewer, new-automation route, tiktok task v4, misc fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ export function App() {
|
||||
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
|
||||
<Route path="/settings/apps" element={<Dashboard.AppsSettings />} />
|
||||
<Route path="/automation" element={<Dashboard.Automation />} />
|
||||
<Route path="/new-automation" element={<Dashboard.NewAutomation />} />
|
||||
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
||||
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
||||
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
||||
@@ -63,6 +64,7 @@ export function App() {
|
||||
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
|
||||
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||
<Route path="/desktop" element={user?.role === 'Super Admin' ? <Dashboard.DesktopScreen /> : <Navigate to="/" replace />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const DesktopScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/desktop', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={workspace} locked />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'desktop-screen',
|
||||
appType: 'officerdev/desktop',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './DesktopScreen';
|
||||
@@ -110,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
};
|
||||
|
||||
|
||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe } from 'lucide-react';
|
||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe, MonitorSmartphone } from 'lucide-react';
|
||||
|
||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
||||
@@ -124,6 +124,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899', role: 'Super Admin' },
|
||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
|
||||
const LeftPanel = () => {
|
||||
return <div className="h-full w-full p-4" />;
|
||||
};
|
||||
|
||||
const RightPanel = () => {
|
||||
return <div className="h-full w-full p-4" />;
|
||||
};
|
||||
|
||||
const layout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'new-automation-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'new-automation-left', appType: null }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'new-automation-right', appType: null }, size: 70 },
|
||||
],
|
||||
};
|
||||
|
||||
export const NewAutomation = () => {
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'new-automation-left': LeftPanel,
|
||||
'new-automation-right': RightPanel,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+28
-9
@@ -12,6 +12,7 @@ type TelegramConnection = {
|
||||
type TelegramStatus = {
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
botUsername: string | null;
|
||||
serverInvite: string | null;
|
||||
botHandle: string | null;
|
||||
};
|
||||
@@ -23,6 +24,7 @@ export const TelegramAccount = () => {
|
||||
const [botStatus, setBotStatus] = useState<TelegramStatus>({
|
||||
configured: false,
|
||||
running: false,
|
||||
botUsername: null,
|
||||
serverInvite: null,
|
||||
botHandle: null,
|
||||
});
|
||||
@@ -81,8 +83,23 @@ export const TelegramAccount = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const connectionInfo = (botStatus.serverInvite || botStatus.botHandle) && (
|
||||
const botHandle = botStatus.botUsername ? `@${botStatus.botUsername}` : botStatus.botHandle;
|
||||
const botLink = botStatus.botUsername ? `https://t.me/${botStatus.botUsername}` : null;
|
||||
|
||||
const connectionInfo = (botHandle || botStatus.serverInvite) && (
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
|
||||
{botHandle && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot:</span>
|
||||
{botLink ? (
|
||||
<a href={botLink} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
{botHandle}
|
||||
</a>
|
||||
) : (
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">{botHandle}</code>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{botStatus.serverInvite && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Group:</span>
|
||||
@@ -96,12 +113,6 @@ export const TelegramAccount = () => {
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{botStatus.botHandle && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot:</span>
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">{botStatus.botHandle}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -131,7 +142,7 @@ export const TelegramAccount = () => {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||
Link your Telegram account to chat with your PI agent via direct messages.
|
||||
Link your Telegram account to chat with your agent via direct messages{botHandle ? ` to ${botHandle}` : ''}.
|
||||
</p>
|
||||
|
||||
{connectionInfo}
|
||||
@@ -153,7 +164,15 @@ export const TelegramAccount = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Send this code as a direct message to the bot on Telegram. Expires in 10 minutes.
|
||||
Send this code as a direct message to{' '}
|
||||
{botLink ? (
|
||||
<a href={botLink} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
{botHandle}
|
||||
</a>
|
||||
) : (
|
||||
<strong>{botHandle ?? 'the bot'}</strong>
|
||||
)}{' '}
|
||||
on Telegram. Expires in 10 minutes.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './Processes';
|
||||
export * from './CapabilityPage';
|
||||
export * from './Settings';
|
||||
export * from './Automation';
|
||||
export * from './NewAutomation';
|
||||
export * from './Skills';
|
||||
export * from './TaskLogs';
|
||||
export * from './Tasks';
|
||||
@@ -18,3 +19,4 @@ export * from './Projects';
|
||||
export * from './Terminal';
|
||||
export * from './Email';
|
||||
export * from './Browser';
|
||||
export * from './Desktop';
|
||||
|
||||
+13
-2
@@ -8,6 +8,7 @@ import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/
|
||||
import { piWebsocket } from './servers/api/pi/websocket';
|
||||
import { cliampWebsocket } from './servers/api/cliamp/websocket';
|
||||
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
|
||||
import { desktopWebsocket } from './servers/api/desktop/websocket';
|
||||
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
||||
import officerWeb from './apps/officer-web/index.html';
|
||||
import { startBrowserRelay } from './servers/api/browser/relay';
|
||||
@@ -20,7 +21,7 @@ type WSData = {
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
provider: 'terminal' | 'pi' | 'dev-server' | 'cliamp' | 'cliamp-audio';
|
||||
provider: 'terminal' | 'pi' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop';
|
||||
sandboxed: boolean;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
@@ -39,6 +40,7 @@ const handlers: Record<string, any> = {
|
||||
pi: piWebsocket,
|
||||
cliamp: cliampWebsocket,
|
||||
'cliamp-audio': cliampAudioWebsocket,
|
||||
desktop: desktopWebsocket,
|
||||
};
|
||||
|
||||
// Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.)
|
||||
@@ -106,7 +108,7 @@ const devServerWebsocket = {
|
||||
};
|
||||
handlers['dev-server'] = devServerWebsocket;
|
||||
|
||||
async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' | 'cliamp' | 'cliamp-audio') {
|
||||
async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' | 'cliamp' | 'cliamp-audio' | 'desktop') {
|
||||
const token = new URL(req.url).searchParams.get('token');
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
@@ -172,6 +174,14 @@ const server = serve({
|
||||
maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB
|
||||
routes: {
|
||||
'/og-image.jpg': () => new Response(Bun.file('public/og-image.jpg'), { headers: { 'Content-Type': 'image/jpeg' } }),
|
||||
'/novnc/*': (req) => {
|
||||
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
||||
return new Response(file, { headers: { 'Content-Type': 'application/javascript' } });
|
||||
},
|
||||
'/vendor/*': (req) => {
|
||||
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
||||
return new Response(file, { headers: { 'Content-Type': 'application/javascript' } });
|
||||
},
|
||||
'/api/dev-server-proxy/*': (req, server) => {
|
||||
if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server);
|
||||
return honoServer.fetch(req, server);
|
||||
@@ -180,6 +190,7 @@ const server = serve({
|
||||
'/api/pi/chat/ws': (req, server) => upgradeWs(req, server, 'pi'),
|
||||
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
|
||||
'/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'),
|
||||
'/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'),
|
||||
'/': officerWeb,
|
||||
'/*': officerWeb,
|
||||
'/api': honoServer.fetch,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
|
||||
export const desktopRouter = createRouter();
|
||||
|
||||
desktopRouter.get('/vnc-password', (ctx) => {
|
||||
const password = process.env.VNC_PASSWORD;
|
||||
if (!password) {
|
||||
return ctx.json({ error: 'VNC password not configured' }, 500);
|
||||
}
|
||||
return ctx.json({ password });
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import type { Socket } from 'bun';
|
||||
|
||||
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string };
|
||||
|
||||
type VncSession = {
|
||||
tcpSocket: Socket<{ ws: ServerWebSocket<WSData> }> | null;
|
||||
pendingMessages: Buffer[];
|
||||
};
|
||||
|
||||
const VNC_PORT = Number(process.env.VNC_PORT || 5901);
|
||||
|
||||
const sessions = new Map<ServerWebSocket<WSData>, VncSession>();
|
||||
|
||||
export const desktopWebsocket = {
|
||||
async open(ws: ServerWebSocket<WSData>) {
|
||||
if (ws.data.role !== 'Super Admin') {
|
||||
ws.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const session: VncSession = { tcpSocket: null, pendingMessages: [] };
|
||||
sessions.set(ws, session);
|
||||
|
||||
try {
|
||||
const tcpSocket = await Bun.connect({
|
||||
hostname: '127.0.0.1',
|
||||
port: VNC_PORT,
|
||||
socket: {
|
||||
data(_socket, data) {
|
||||
try {
|
||||
ws.sendBinary(Buffer.from(data));
|
||||
} catch {
|
||||
// ws already closed
|
||||
}
|
||||
},
|
||||
close() {
|
||||
sessions.delete(ws);
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
},
|
||||
error(_socket, err) {
|
||||
console.error('[desktop] TCP error:', err.message);
|
||||
sessions.delete(ws);
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
},
|
||||
connectError(_socket, err) {
|
||||
console.error('[desktop] TCP connect error:', err.message);
|
||||
sessions.delete(ws);
|
||||
try { ws.close(4004, 'VNC server unavailable'); } catch { /* ignore */ }
|
||||
},
|
||||
open(socket) {
|
||||
// Flush any pending messages
|
||||
for (const msg of session.pendingMessages) {
|
||||
socket.write(msg);
|
||||
}
|
||||
session.pendingMessages = [];
|
||||
},
|
||||
},
|
||||
data: { ws },
|
||||
});
|
||||
|
||||
session.tcpSocket = tcpSocket;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect to VNC server';
|
||||
console.error('[desktop] VNC connect failed:', message);
|
||||
sessions.delete(ws);
|
||||
ws.close(4004, 'VNC server unavailable');
|
||||
}
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||
const session = sessions.get(ws);
|
||||
if (!session) return;
|
||||
|
||||
const data = typeof raw === 'string' ? Buffer.from(raw) : Buffer.from(raw);
|
||||
|
||||
if (!session.tcpSocket) {
|
||||
session.pendingMessages.push(data);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.tcpSocket.write(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
const session = sessions.get(ws);
|
||||
if (session?.tcpSocket) {
|
||||
try {
|
||||
session.tcpSocket.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
sessions.delete(ws);
|
||||
},
|
||||
|
||||
drain() {},
|
||||
};
|
||||
@@ -29,13 +29,27 @@ async function cleanOldCacheDirs(userDataDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOnboarding(homeDir: string) {
|
||||
const target = join(homeDir, 'Onboarding');
|
||||
if (!existsSync(ONBOARDING_SEED)) return;
|
||||
|
||||
await mkdir(target, { recursive: true });
|
||||
|
||||
// Copy any files from seed that are missing in the user's dir
|
||||
const seedEntries = await readdir(ONBOARDING_SEED, { withFileTypes: true });
|
||||
for (const entry of seedEntries) {
|
||||
const dest = join(target, entry.name);
|
||||
if (existsSync(dest)) continue;
|
||||
await cp(join(ONBOARDING_SEED, entry.name), dest, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function seedHomeDir(homeDir: string) {
|
||||
for (const dir of DEFAULT_HOME_DIRS) {
|
||||
const target = join(homeDir, dir);
|
||||
if (existsSync(target)) continue;
|
||||
if (dir === 'Onboarding' && existsSync(ONBOARDING_SEED)) {
|
||||
await cp(ONBOARDING_SEED, target, { recursive: true });
|
||||
} else {
|
||||
if (dir === 'Onboarding') {
|
||||
await syncOnboarding(homeDir);
|
||||
} else if (!existsSync(target)) {
|
||||
await mkdir(target, { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -905,8 +919,8 @@ router.post('/download-video', async (ctx) => {
|
||||
|
||||
const absPath = resolveUserPath(rootDir, path);
|
||||
await mkdir(absPath, { recursive: true });
|
||||
const cookiesPath = join(DATA_PATH, '..', 'yt-dlp-cookies.txt');
|
||||
const args = ['yt-dlp', '--js-runtimes', 'bun', '--cookies', cookiesPath, '-o', '%(title)s.%(ext)s'];
|
||||
const ytdlp = Bun.which('yt-dlp') ?? `${process.env.HOME}/.local/bin/yt-dlp`;
|
||||
const args = [ytdlp, '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--cookies-from-browser', 'brave', '-o', '%(title)s.%(ext)s'];
|
||||
if (audioOnly) args.push('-x', '--audio-format', 'mp3');
|
||||
args.push(url);
|
||||
|
||||
|
||||
@@ -563,3 +563,19 @@ export function killPi(process: Subprocess): void {
|
||||
// Already dead
|
||||
}
|
||||
}
|
||||
|
||||
/** Build env vars needed by Officer tools when running on the host. */
|
||||
export async function buildHostToolEnv(userId: number, email: string): Promise<Record<string, string>> {
|
||||
const browserRelayEnv = await getBrowserRelayEnv(userId);
|
||||
const apifyToken = await getApifyToken();
|
||||
|
||||
return {
|
||||
HOME: getHomeDir(email),
|
||||
OFFICER_USER_HOME: getHomeDir(email),
|
||||
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
||||
OFFICER_RESOURCES: buildResourcesEnv(),
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}),
|
||||
...browserRelayEnv,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ piRestRouter.get('/pi/models', async (ctx: Context) => {
|
||||
try {
|
||||
const models = await listPiModels();
|
||||
|
||||
// Build providerNames map for officer-local-* providers
|
||||
const providerNames: Record<string, string> = {};
|
||||
// Build providerNames map
|
||||
const providerNames: Record<string, string> = { 'claude-code': 'Claude Code' };
|
||||
const localProviders = await readLocalProviders();
|
||||
for (const lp of localProviders) {
|
||||
providerNames[`officer-local-${lp.id}`] = lp.name;
|
||||
|
||||
+102
-11
@@ -4,6 +4,7 @@ import type { ClientMessage, ServerMessage, Message, PiEvent } from './types';
|
||||
import { sessionManager } from './session-manager';
|
||||
import * as storage from './storage';
|
||||
import * as piBridge from './pi-bridge';
|
||||
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||
import { join, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getHomeDir } from '../../../servers/data-path';
|
||||
@@ -11,7 +12,7 @@ import { getUserSettings } from 'officerdb';
|
||||
import { logger } from './logger';
|
||||
|
||||
// Default model when no user preference is set
|
||||
const DEFAULT_MODEL = 'opencode/big-pickle';
|
||||
const DEFAULT_MODEL = 'claude-code';
|
||||
|
||||
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
||||
try {
|
||||
@@ -262,14 +263,18 @@ async function handleChat(
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Model selected for chat', {
|
||||
sessionId,
|
||||
model,
|
||||
logger.info('Model selected for chat', {
|
||||
sessionId,
|
||||
model,
|
||||
modelSource,
|
||||
clientModel: msg.model || null,
|
||||
userDefault,
|
||||
});
|
||||
|
||||
|
||||
if (model === 'claude-code') {
|
||||
return handleClaudeCodeChat(ws, sessionId, model, msg);
|
||||
}
|
||||
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandboxed = msg.sandboxed ?? false;
|
||||
const cwd = sandboxed
|
||||
@@ -351,6 +356,86 @@ async function handleChat(
|
||||
piBridge.sendPrompt(session.piProcess, msg.prompt, requestId);
|
||||
}
|
||||
|
||||
async function handleClaudeCodeChat(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean },
|
||||
): Promise<void> {
|
||||
const { email, username, userId } = ws.data;
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandboxed = msg.sandboxed ?? false;
|
||||
|
||||
// Claude Code always operates on the user's data home (not OS home).
|
||||
// Resolve cwd relative to data directory, then remap for container if sandboxed.
|
||||
const dataCwd = resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd);
|
||||
let cwd: string;
|
||||
if (sandboxed) {
|
||||
const containerHome = `/home/${username}`;
|
||||
cwd = dataCwd.startsWith(homeDir)
|
||||
? `${containerHome}${dataCwd.slice(homeDir.length)}`
|
||||
: containerHome;
|
||||
} else {
|
||||
cwd = dataCwd;
|
||||
}
|
||||
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
||||
session.sandboxed = sandboxed;
|
||||
session.userId = userId;
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
|
||||
|
||||
// Add user message to session
|
||||
const userMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'user',
|
||||
text: msg.prompt,
|
||||
};
|
||||
session.messages.push(userMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
if (!session.meta.title) {
|
||||
session.meta.title = (msg.displayText ?? msg.prompt).slice(0, 100);
|
||||
}
|
||||
|
||||
session.isGenerating = true;
|
||||
|
||||
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
||||
|
||||
try {
|
||||
const handle = await sendClaudeCodeStreaming({
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
prompt: msg.prompt,
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
sandboxed,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
// Store proc as piProcess so handleStop can kill it
|
||||
session.piProcess = handle.proc;
|
||||
|
||||
// Null out when process exits so next message spawns a new one
|
||||
handle.proc.exited.then(() => {
|
||||
if (session.piProcess === handle.proc) {
|
||||
session.piProcess = null;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to start Claude Code streaming', { sessionId, error: String(err) });
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to start Claude Code' });
|
||||
session.isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResume(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { sessionId: string; cwd?: string; cwdRoot?: string }
|
||||
@@ -444,21 +529,27 @@ async function handleResume(
|
||||
|
||||
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
|
||||
|
||||
if (sessionId) {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
|
||||
|
||||
if (session?.piProcess) {
|
||||
try {
|
||||
piBridge.abort(session.piProcess, randomUUID());
|
||||
logger.info('Sent abort to Pi process', { sessionId });
|
||||
if (session.model === 'claude-code') {
|
||||
// Claude Code: kill the docker exec process directly
|
||||
session.piProcess.kill();
|
||||
logger.info('Killed Claude Code process', { sessionId });
|
||||
} else {
|
||||
piBridge.abort(session.piProcess, randomUUID());
|
||||
logger.info('Sent abort to Pi process', { sessionId });
|
||||
}
|
||||
session.isGenerating = false;
|
||||
} catch (err) {
|
||||
logger.error('Failed to abort Pi process', { sessionId, error: String(err) });
|
||||
logger.error('Failed to stop process', { sessionId, error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sendToClient(ws, { type: 'stopped' });
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ ENV PATH="/usr/local/cargo/bin:${PATH}"
|
||||
|
||||
RUN npm install -g @mariozechner/pi-coding-agent @anthropic-ai/claude-code
|
||||
|
||||
# Patch Pi compaction bug: calculateContextTokens crashes when usage is undefined
|
||||
RUN sed -i '/^export function calculateContextTokens(usage) {$/a\ if (!usage) return 0;' \
|
||||
/usr/local/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/compaction/compaction.js
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
ENV TERMINAL_PTY_PORT=5337
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { existsSync, symlinkSync, rmdirSync } from 'node:fs';
|
||||
import { ensureDockerContainer } from '@@/api/terminal/websocket';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
import { getHomeDir, DATA_PATH, getNativeToolsDir, getGlobalToolsDir, getUserToolsDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path';
|
||||
import { readToolDirs, parseFrontmatter as parseToolFrontmatter } from '@@/api/tools/tools';
|
||||
import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills';
|
||||
import { buildHostToolEnv } from '@@/api/pi/pi-bridge';
|
||||
import { logger } from '@@/api/pi/logger';
|
||||
import type { MessageCost } from '@@/api/pi/types';
|
||||
import type { MessageCost, PiEvent } from '@@/api/pi/types';
|
||||
|
||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -36,6 +42,86 @@ export function clearClaudeCodeSession(sessionKey: string): void {
|
||||
claudeCodeSessions.delete(sessionKey);
|
||||
}
|
||||
|
||||
// ── Build dynamic system prompt from available tools & skills ──
|
||||
|
||||
async function buildToolsSystemPrompt(email: string): Promise<string | null> {
|
||||
const [nativeTools, globalTools, userTools, nativeSkills, globalSkills, userSkills] = await Promise.all([
|
||||
readToolDirs(getNativeToolsDir()),
|
||||
readToolDirs(getGlobalToolsDir()),
|
||||
readToolDirs(getUserToolsDir(email)),
|
||||
readSkillDirs(getNativeSkillsDir()),
|
||||
readSkillDirs(getGlobalSkillsDir()),
|
||||
readSkillDirs(getUserSkillsDir(email)),
|
||||
]);
|
||||
|
||||
// Merge tools (user overrides global overrides native)
|
||||
const mergedTools = new Map(nativeTools);
|
||||
for (const [name, path] of globalTools) mergedTools.set(name, path);
|
||||
for (const [name, path] of userTools) mergedTools.set(name, path);
|
||||
|
||||
// Merge skills
|
||||
const mergedSkills = new Map(nativeSkills);
|
||||
for (const [name, path] of globalSkills) mergedSkills.set(name, path);
|
||||
for (const [name, path] of userSkills) mergedSkills.set(name, path);
|
||||
|
||||
if (mergedTools.size === 0 && mergedSkills.size === 0) return null;
|
||||
|
||||
const sections: string[] = [
|
||||
'# Officer Automation System',
|
||||
'',
|
||||
'You are running inside the Officer platform. Officer has its own automation concepts that are DIFFERENT from your built-in tools. When the user or a task references these, use the definitions below — do NOT map them to your own built-in concepts.',
|
||||
'',
|
||||
'- **Task**: A markdown file (TASK.md) with instructions for you to execute. When asked to "run a task", read the TASK.md file and follow its instructions step by step.',
|
||||
'- **Skill**: A knowledge document (SKILL.md) that describes HOW to do something — APIs, commands, patterns. When a task says "use the X skill", follow the instructions from the matching skill section below.',
|
||||
'- **Tool**: A capability defined by a TOOL.md and implemented in an index.ts file. Tools are NOT shell commands — do NOT try to call them by name. If the tool has a "Run" line below, execute it using that command, passing inputs as a JSON string argument. Only if there is no Run command should you replicate the behavior manually using the TOOL.md documentation.',
|
||||
'',
|
||||
'Skills and tools listed here are AVAILABLE to you. Follow their documentation directly.',
|
||||
'',
|
||||
];
|
||||
|
||||
if (mergedTools.size > 0) {
|
||||
const toolLines: string[] = ['# Tools', ''];
|
||||
const entries = await Promise.all(
|
||||
Array.from(mergedTools.entries()).map(async ([dirName, filePath]) => {
|
||||
const raw = await Bun.file(filePath).text();
|
||||
const { frontmatter, body } = parseToolFrontmatter(raw);
|
||||
const toolDir = dirname(filePath);
|
||||
const hasImpl = await Bun.file(`${toolDir}/index.ts`).exists();
|
||||
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body, toolDir, hasImpl };
|
||||
}),
|
||||
);
|
||||
const runnerPath = `${getNativeToolsDir()}/run.ts`;
|
||||
for (const tool of entries) {
|
||||
toolLines.push(`## ${tool.name}`);
|
||||
if (tool.hasImpl) toolLines.push(`Run: \`bun run ${runnerPath} ${tool.toolDir} '{"param":"value"}'\``);
|
||||
if (tool.description) toolLines.push(tool.description);
|
||||
if (tool.body.trim()) toolLines.push('', tool.body.trim());
|
||||
toolLines.push('');
|
||||
}
|
||||
sections.push(toolLines.join('\n'));
|
||||
}
|
||||
|
||||
if (mergedSkills.size > 0) {
|
||||
const skillLines: string[] = ['# Skills', ''];
|
||||
const entries = await Promise.all(
|
||||
Array.from(mergedSkills.entries()).map(async ([dirName, filePath]) => {
|
||||
const raw = await Bun.file(filePath).text();
|
||||
const { frontmatter, body } = parseSkillFrontmatter(raw);
|
||||
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body };
|
||||
}),
|
||||
);
|
||||
for (const skill of entries) {
|
||||
skillLines.push(`## ${skill.name}`);
|
||||
if (skill.description) skillLines.push(skill.description);
|
||||
if (skill.body.trim()) skillLines.push('', skill.body.trim());
|
||||
skillLines.push('');
|
||||
}
|
||||
sections.push(skillLines.join('\n'));
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
|
||||
const { userId, email, username, prompt, sessionKey } = params;
|
||||
const homeDir = getHomeDir(email);
|
||||
@@ -137,3 +223,291 @@ export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCo
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming variant for Chat Panel WebSocket ──
|
||||
|
||||
type ClaudeCodeStreamingParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
sessionKey: string;
|
||||
cwd?: string;
|
||||
sandboxed?: boolean;
|
||||
onEvent: (event: PiEvent) => void;
|
||||
};
|
||||
|
||||
type ClaudeCodeStreamingHandle = {
|
||||
proc: ReturnType<typeof Bun.spawn>;
|
||||
};
|
||||
|
||||
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
|
||||
const { userId, email, username, prompt, sessionKey, cwd, sandboxed = false, onEvent } = params;
|
||||
|
||||
const claudeArgs = [
|
||||
'claude', '-p', prompt,
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format', 'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
];
|
||||
|
||||
const existingSession = claudeCodeSessions.get(sessionKey);
|
||||
if (existingSession) {
|
||||
claudeArgs.push('--resume', existingSession);
|
||||
}
|
||||
|
||||
// Append dynamic system prompt with available tools & skills
|
||||
const systemPrompt = await buildToolsSystemPrompt(email);
|
||||
if (systemPrompt) {
|
||||
claudeArgs.push('--append-system-prompt', systemPrompt);
|
||||
}
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
if (sandboxed) {
|
||||
// Container execution via docker exec
|
||||
const homeDir = getHomeDir(email);
|
||||
const container = await ensureDockerContainer(email, userId, homeDir, username);
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const containerId = container.dockerId;
|
||||
const containerHome = `/home/${username}`;
|
||||
const workDir = cwd ?? containerHome;
|
||||
|
||||
const args = [
|
||||
dockerPath, 'exec',
|
||||
'-u', username,
|
||||
'-w', workDir,
|
||||
'-e', `HOME=${containerHome}`,
|
||||
containerId,
|
||||
...claudeArgs,
|
||||
];
|
||||
|
||||
logger.info('Claude Code streaming exec (container)', { sessionKey, containerId, resume: existingSession ?? null });
|
||||
|
||||
proc = Bun.spawn(args, {
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
} else {
|
||||
// Host execution — run claude directly
|
||||
const claudePath = Bun.which('claude') ?? 'claude';
|
||||
claudeArgs[0] = claudePath;
|
||||
const workDir = cwd ?? homedir();
|
||||
|
||||
logger.info('Claude Code streaming exec (host)', { sessionKey, cwd: workDir, resume: existingSession ?? null });
|
||||
|
||||
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
||||
const toolEnv = await buildHostToolEnv(userId, email);
|
||||
|
||||
// Ensure Claude Code can find ~/.claude credentials in the user's data home.
|
||||
// Symlink the host's .claude config into the data home if not already there.
|
||||
const dataHome = toolEnv.HOME!;
|
||||
const hostClaudeConfig = join(homedir(), '.claude');
|
||||
const targetClaudeConfig = join(dataHome, '.claude');
|
||||
const targetCredentials = join(targetClaudeConfig, '.credentials.json');
|
||||
if (!existsSync(targetCredentials) && existsSync(hostClaudeConfig)) {
|
||||
try {
|
||||
// Remove empty placeholder dir if it exists, then symlink
|
||||
if (existsSync(targetClaudeConfig)) rmdirSync(targetClaudeConfig);
|
||||
symlinkSync(hostClaudeConfig, targetClaudeConfig);
|
||||
} catch { /* race, permission, or non-empty dir */ }
|
||||
}
|
||||
|
||||
proc = Bun.spawn(claudeArgs, {
|
||||
cwd: workDir,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...cleanEnv, ...toolEnv },
|
||||
});
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
onEvent({ type: 'error', message: 'Claude Code timed out after 5 minutes' });
|
||||
}, SEND_TIMEOUT_MS);
|
||||
|
||||
// Process NDJSON stream in background
|
||||
(async () => {
|
||||
try {
|
||||
const stdout = proc.stdout as ReadableStream<Uint8Array>;
|
||||
const reader = stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let textBuffer = '';
|
||||
let gotResult = false;
|
||||
|
||||
let lineCount = 0;
|
||||
const processLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
lineCount++;
|
||||
try {
|
||||
const msg = JSON.parse(line) as Record<string, unknown>;
|
||||
const type = msg.type as string;
|
||||
if (lineCount <= 5 || type === 'result') {
|
||||
logger.info('Claude Code NDJSON', { sessionKey, lineCount, type, subtype: msg.subtype ?? null });
|
||||
}
|
||||
|
||||
// stream_event — partial streaming (text deltas)
|
||||
if (type === 'stream_event') {
|
||||
const event = msg.event as Record<string, unknown> | undefined;
|
||||
if (event?.type === 'content_block_delta') {
|
||||
const delta = event.delta as Record<string, unknown> | undefined;
|
||||
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
||||
textBuffer += delta.text;
|
||||
onEvent({ type: 'delta', text: delta.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assistant — complete message with text and tool_use blocks
|
||||
else if (type === 'assistant') {
|
||||
const message = msg.message as Record<string, unknown> | undefined;
|
||||
const content = message?.content as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
// Full text block — emit as text event, reset streaming buffer
|
||||
onEvent({ type: 'text', text: block.text });
|
||||
textBuffer = '';
|
||||
} else if (block.type === 'tool_use') {
|
||||
// Flush any pending streamed text before tool
|
||||
if (textBuffer) {
|
||||
onEvent({ type: 'text', text: textBuffer });
|
||||
textBuffer = '';
|
||||
}
|
||||
onEvent({
|
||||
type: 'tool:start',
|
||||
toolCallId: (block.id as string) ?? '',
|
||||
toolName: (block.name as string) ?? 'unknown',
|
||||
toolInput: (block.input as Record<string, unknown>) ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// user — tool results
|
||||
else if (type === 'user') {
|
||||
const message = msg.message as Record<string, unknown> | undefined;
|
||||
const content = message?.content as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result') {
|
||||
let output = '';
|
||||
if (typeof block.content === 'string') {
|
||||
output = block.content;
|
||||
} else if (Array.isArray(block.content)) {
|
||||
output = (block.content as Array<Record<string, unknown>>)
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => c.text as string)
|
||||
.join('\n');
|
||||
}
|
||||
onEvent({
|
||||
type: 'tool:result',
|
||||
toolCallId: (block.tool_use_id as string) ?? '',
|
||||
output,
|
||||
isError: (block.is_error as boolean) ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// system init — extract session_id for --resume
|
||||
else if (type === 'system' && msg.subtype === 'init') {
|
||||
const sessionId = msg.session_id as string | undefined;
|
||||
if (sessionId) {
|
||||
claudeCodeSessions.set(sessionKey, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// result — final
|
||||
else if (type === 'result') {
|
||||
gotResult = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
const isError = (msg.is_error as boolean) ?? false;
|
||||
const resultText = (msg.result as string) ?? '';
|
||||
|
||||
if (isError) {
|
||||
logger.info('Claude Code streaming error result', { sessionKey, error: resultText.slice(0, 500) });
|
||||
if (textBuffer) {
|
||||
onEvent({ type: 'text', text: textBuffer });
|
||||
textBuffer = '';
|
||||
}
|
||||
onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (textBuffer) {
|
||||
onEvent({ type: 'text', text: textBuffer });
|
||||
textBuffer = '';
|
||||
}
|
||||
|
||||
const usage = msg.usage as Record<string, number> | undefined;
|
||||
const cost: MessageCost = {
|
||||
inputTokens: usage?.input_tokens ?? 0,
|
||||
outputTokens: usage?.output_tokens ?? 0,
|
||||
totalUSD: (msg.total_cost_usd as number) ?? 0,
|
||||
};
|
||||
|
||||
const sessionId = msg.session_id as string | undefined;
|
||||
if (sessionId) {
|
||||
claudeCodeSessions.set(sessionKey, sessionId);
|
||||
}
|
||||
|
||||
logger.info('Claude Code streaming result', { sessionKey, cost: cost.totalUSD });
|
||||
onEvent({ type: 'result', cost });
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSON lines
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop()!;
|
||||
|
||||
for (const line of lines) {
|
||||
processLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining buffer
|
||||
if (buffer.trim()) {
|
||||
processLine(buffer);
|
||||
}
|
||||
|
||||
logger.info('Claude Code stream ended', { sessionKey, totalLines: lineCount, gotResult });
|
||||
clearTimeout(timeout);
|
||||
|
||||
// If process exited without a result event, emit error or synthetic result
|
||||
if (!gotResult) {
|
||||
const exitCode = await proc.exited;
|
||||
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
|
||||
logger.info('Claude Code exited without result event', { sessionKey, exitCode, stderr: stderr.trim().slice(0, 500) });
|
||||
if (textBuffer) {
|
||||
onEvent({ type: 'text', text: textBuffer });
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
onEvent({ type: 'error', message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}` });
|
||||
} else {
|
||||
onEvent({ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
onEvent({ type: 'error', message: String(err) });
|
||||
}
|
||||
})();
|
||||
|
||||
return { proc };
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { queueRouter } from './api/queue/queue';
|
||||
import { emailRouter } from './api/email/email';
|
||||
import { channelsRouter } from './channels/routes';
|
||||
import { browserRouter } from './api/browser/router';
|
||||
import { desktopRouter } from './api/desktop/rest';
|
||||
import { appsRouter, appServeRouter } from './api/apps';
|
||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
||||
@@ -99,6 +100,8 @@ protectedRouter.route('/channels', channelsRouter);
|
||||
protectedRouter.route('/browser', browserRouter);
|
||||
protectedRouter.route('/apps', appsRouter);
|
||||
protectedRouter.route('/bug-report', bugReportRouter);
|
||||
desktopRouter.use(superAdminMiddleware);
|
||||
protectedRouter.route('/desktop', desktopRouter);
|
||||
protectedRouter.route('/', piRestRouter);
|
||||
|
||||
honoServer.route('/api', protectedRouter);
|
||||
|
||||
@@ -9,13 +9,14 @@ import { appRegistryMetas as projectMetas } from '../apps/Projects';
|
||||
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { appRegistryMetas as previewMetas } from '../apps/Preview';
|
||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||
import { useAppRegistry } from './useAppRegistry';
|
||||
import { useUserApps } from 'state/useUserApps';
|
||||
import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
|
||||
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
||||
import { resolveIcon } from '../utils/resolve-icon';
|
||||
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas];
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas];
|
||||
|
||||
export const AppRegistry = () => {
|
||||
const { registerApp } = useAppRegistry(apps);
|
||||
|
||||
@@ -18,6 +18,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
bedrock: 'Amazon Bedrock',
|
||||
'google-vertex': 'Google Vertex AI',
|
||||
'azure-openai': 'Azure OpenAI',
|
||||
'claude-code': 'Claude Code',
|
||||
};
|
||||
|
||||
type ModelSelectorProps = {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { MonitorSmartphone } from 'lucide-react';
|
||||
|
||||
export const DesktopHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<MonitorSmartphone className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Remote Desktop</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
|
||||
export type DesktopViewProps = {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
const NOVNC_URL = '/novnc/rfb.js';
|
||||
|
||||
const buildWsUrl = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||
return `${protocol}//${window.location.host}/api/desktop/ws?token=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
type RFBInstance = {
|
||||
resizeSession: boolean;
|
||||
scaleViewport: boolean;
|
||||
focusOnClick: boolean;
|
||||
disconnect: () => void;
|
||||
sendCredentials: (creds: { password: string }) => void;
|
||||
addEventListener: (type: string, listener: (ev: CustomEvent) => void) => void;
|
||||
};
|
||||
|
||||
let rfbModulePromise: Promise<{ default: new (target: HTMLElement, url: string, options?: { credentials?: { password?: string } }) => RFBInstance }> | null = null;
|
||||
|
||||
const loadRFB = () => {
|
||||
if (!rfbModulePromise) {
|
||||
rfbModulePromise = import(/* @vite-ignore */ NOVNC_URL) as typeof rfbModulePromise;
|
||||
}
|
||||
return rfbModulePromise!;
|
||||
};
|
||||
|
||||
export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rfbRef = useRef<RFBInstance | null>(null);
|
||||
const isMounted = useMounted();
|
||||
const client = useClient();
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const connect = async () => {
|
||||
let password = '';
|
||||
try {
|
||||
const res = await client.get<{ password: string }>('/desktop/vnc-password');
|
||||
password = res.password;
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
setStatus('error');
|
||||
setErrorMsg('Failed to fetch VNC password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
let RFB: Awaited<ReturnType<typeof loadRFB>>['default'];
|
||||
try {
|
||||
const mod = await loadRFB();
|
||||
RFB = mod.default;
|
||||
} catch (err) {
|
||||
console.error('[desktop] Failed to load noVNC:', err);
|
||||
if (disposed) return;
|
||||
setStatus('error');
|
||||
setErrorMsg('Failed to load noVNC library');
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
const wsUrl = buildWsUrl();
|
||||
const rfb = new RFB(container, wsUrl, {
|
||||
credentials: { password },
|
||||
});
|
||||
|
||||
rfb.resizeSession = true;
|
||||
rfb.scaleViewport = true;
|
||||
rfb.focusOnClick = true;
|
||||
rfbRef.current = rfb;
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
if (!disposed) setStatus('connected');
|
||||
});
|
||||
|
||||
rfb.addEventListener('disconnect', (ev: CustomEvent) => {
|
||||
if (disposed) return;
|
||||
setStatus('disconnected');
|
||||
if (!ev.detail.clean) {
|
||||
setErrorMsg('Connection lost');
|
||||
}
|
||||
});
|
||||
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
rfb.sendCredentials({ password });
|
||||
});
|
||||
|
||||
rfb.addEventListener('securityfailure', (ev: CustomEvent) => {
|
||||
if (!disposed) {
|
||||
setStatus('error');
|
||||
setErrorMsg(ev.detail.reason || 'Security failure');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (rfbRef.current) {
|
||||
try { rfbRef.current.disconnect(); } catch { /* ignore */ }
|
||||
rfbRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isMounted, client]);
|
||||
|
||||
return (
|
||||
<div className={className} style={{ backgroundColor: '#1a1a2e', overflow: 'hidden', position: 'relative', ...style }}>
|
||||
{status === 'connecting' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Connecting to desktop...
|
||||
</div>
|
||||
)}
|
||||
{(status === 'disconnected' || status === 'error') && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{errorMsg || 'Disconnected from desktop'}
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { DesktopView } from './DesktopView';
|
||||
|
||||
export const DesktopWrapper = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user?.role !== 'Super Admin') {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
||||
Remote Desktop requires Super Admin permissions.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DesktopView className="h-full w-full" />;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { MonitorSmartphone } from 'lucide-react';
|
||||
import { DesktopWrapper } from './DesktopWrapper';
|
||||
import { DesktopHeader } from './DesktopHeader';
|
||||
|
||||
export { DesktopView, type DesktopViewProps } from './DesktopView';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/desktop',
|
||||
name: 'Remote Desktop',
|
||||
icon: MonitorSmartphone,
|
||||
component: DesktopWrapper,
|
||||
header: DesktopHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare } from 'lucide-react';
|
||||
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
@@ -124,10 +124,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Dashboard here
|
||||
</ContextMenuItem>
|
||||
{/* <ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download video
|
||||
</ContextMenuItem> */}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
+11
-2
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Play, Square, CircleCheck } from 'lucide-react';
|
||||
import { X, Play, Square, CircleCheck, CircleX } from 'lucide-react';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
@@ -54,7 +54,7 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
|
||||
const seenResultRef = useRef(false);
|
||||
const [, bump] = useState(0);
|
||||
|
||||
// Track tool/result messages from chat.messages (idempotent during render)
|
||||
// Track tool/result/error 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)) {
|
||||
@@ -72,6 +72,10 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
|
||||
seenResultRef.current = true;
|
||||
accRef.current.push(m);
|
||||
}
|
||||
if (m.role === 'error') {
|
||||
const alreadyHas = accRef.current.some((a) => a.role === 'error' && 'text' in a && a.text === m.text);
|
||||
if (!alreadyHas) accRef.current.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture assistant text when streaming is committed (streamingText goes non-empty → empty)
|
||||
@@ -170,6 +174,11 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop
|
||||
</button>
|
||||
) : accRef.current.some((m) => m.role === 'error') ? (
|
||||
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
|
||||
<CircleX className="h-4 w-4" />
|
||||
Task failed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
|
||||
<CircleCheck className="h-4 w-4" />
|
||||
|
||||
@@ -15,6 +15,8 @@ export { FileViewerView, FileViewerProvider, FileViewerHeader, FileViewerBody, F
|
||||
export type { FileType } from './apps/FileViewer';
|
||||
export { TerminalView } from './apps/Terminal';
|
||||
export type { TerminalViewProps } from './apps/Terminal';
|
||||
export { DesktopView } from './apps/Desktop';
|
||||
export type { DesktopViewProps } from './apps/Desktop';
|
||||
export { DashboardListApp, DashboardPreview, SELECTED_DASHBOARD_KEY, CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './apps/Dashboards';
|
||||
export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, NEW_PROJ_DESC, NEW_PROJ_TEMPLATE, NEW_PROJ_TYPE, NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_AUTH, NEW_PROJ_PREVIEW_LAYOUT } from './apps/Projects';
|
||||
export { createUserAppPanel, createUserAppHeader } from './apps/UserApp';
|
||||
|
||||
@@ -60,6 +60,7 @@ export function useVisiblePiModels() {
|
||||
const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0]));
|
||||
|
||||
return models.filter((m) => {
|
||||
if (m.provider === 'claude-code') return true;
|
||||
const isExplicitlyAllowed = allowedSet.has(modelKey(m));
|
||||
const isFromNewProvider = !allowedProviderSet.has(m.provider);
|
||||
return isExplicitlyAllowed || isFromNewProvider;
|
||||
@@ -75,5 +76,5 @@ export function useEnabledPiModels() {
|
||||
if (allowed.length === 0) return models;
|
||||
|
||||
const allowedSet = new Set(allowed);
|
||||
return models.filter((m) => allowedSet.has(modelKey(m)));
|
||||
return models.filter((m) => m.provider === 'claude-code' || allowedSet.has(modelKey(m)));
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ export type UserState = Record<string, unknown>;
|
||||
export const DEFAULT_SETTINGS: UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'pi',
|
||||
defaultModel: null,
|
||||
defaultProjectModel: null,
|
||||
defaultModel: 'claude-code',
|
||||
defaultProjectModel: 'claude-code',
|
||||
systemPrompt: '',
|
||||
temperature: 1,
|
||||
defaultPwd: '~',
|
||||
@@ -100,7 +100,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
||||
},
|
||||
tasks: {
|
||||
defaultProvider: 'pi',
|
||||
defaultModel: null,
|
||||
defaultModel: 'claude-code',
|
||||
},
|
||||
appearance: {
|
||||
colorMode: 'light',
|
||||
|
||||
Reference in New Issue
Block a user