Compare commits

..

6 Commits

Author SHA1 Message Date
Simos Mikelatos
0610cc8333 fix: browser use set profile root folder 2026-06-24 19:21:52 +00:00
Simos Mikelatos
9457651fdd fix(browser-use): harden browser settings state 2026-06-24 15:36:25 +00:00
Simos Mikelatos
8c31ebcc63 feat(browser-use): add Camoufox noVNC session viewer 2026-06-24 14:39:41 +00:00
Koya Kikuchi
f6326c8082 feat(version): warn when the server was updated but not restarted (#898)
When the package is updated on disk but the long-lived server process is
not restarted, the new frontend bundle (served from disk) talks to the
old running backend. New DB-backed features then fail silently — e.g.
deleting/archiving a session appears to do nothing — because the new
schema/routes only take effect on restart.

Nothing currently detects this skew: useVersionCheck only compares the
frontend's build-time version against the latest GitHub release.

This exposes the running server's version (captured once at startup) via
/health, compares it to the frontend's build-time version in
useVersionCheck, and shows a "restart required" banner in the sidebar
(and a small indicator in the collapsed sidebar) when they differ.

- server: add `version` (RUNNING_VERSION, read once at startup) to /health
- useVersionCheck: return `restartRequired` / `runningVersion`
- SidebarFooter / SidebarCollapsed: surface a restart-required banner
- i18n: add `version.restartRequired` to all 10 sidebar locales

Verified with `tsc --noEmit` (client + server) and eslint.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-22 22:49:57 +02:00
Haile
c5fe127958 feat(skills): add provider skill management (#909)
* feat(skills): add provider skill management

Users need one settings surface to discover and install skills without manually navigating provider-specific directories.

Add provider-backed global skill installation for Claude, Codex, Gemini, and Cursor, while keeping OpenCode read-only because it reuses other providers' skill locations.

Add a responsive Skills settings tab with scoped discovery, search, refresh controls, markdown and folder uploads, upload feedback, and overflow-safe layouts.

Validate bundled skill files and paths before writing them, preserve scripts and assets, and cover provider discovery and installation behavior with tests.

* fix(skills): preserve uploaded skill folders

Folder drops discarded supporting scripts and assets.

Keep relative paths and upload every file from the selected skill folder.

Use the selected folder name for installation and cover it in provider tests.

* fix(skills): restrict standalone skill uploads

Only show Markdown files when selecting standalone skills.

Normalize browser file paths so SKILL.md is not mistaken for a folder named dot.

* fix(skills): validate installs before writing

Preserve bundled files and normalize fallback names across skill installation paths.

Validate complete batches before writing and reject existing targets to avoid partial installs.

Keep project metadata and make folder selection tolerant of casing and cancelled dialogs.

* fix(skills): overwrite existing installations

Replace an existing skill directory instead of rejecting a duplicate installation.

Remove stale supporting files so the installed directory exactly matches the new upload.
2026-06-22 22:45:27 +02:00
chenxiccc
4712431be8 fix(chat): prevent normalizeInlineCodeFences from breaking adjacent fenced code blocks (#903) 2026-06-19 18:40:26 +02:00
52 changed files with 3119 additions and 216 deletions

View File

@@ -69,7 +69,7 @@ const sessionIdSchema = {
const tools: ToolDefinition[] = [
{
name: 'browser_create_session',
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
description: 'Create a Browser session that the agent can control. Provide profileName to use a specific persistent profile; when omitted, the configured persistent profile is used only if session persistence is enabled, otherwise a temporary session is created.',
inputSchema: {
type: 'object',
properties: {

View File

@@ -63,7 +63,7 @@ import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js';
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
import { browserUseService } from './modules/browser-use/browser-use.service.js';
import { browserUseService, VIEWER_COOKIE_NAME } from './modules/browser-use/index.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js';
@@ -76,6 +76,19 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
// Version of the code that is actually running, captured once at process
// startup. This intentionally does NOT re-read package.json per request: after
// an update replaces the files on disk, package.json reflects the NEW version
// while this long-lived process still runs the OLD code. The frontend bundle is
// rebuilt on update, so a mismatch between this value and the frontend's
// build-time version means the server was updated but not restarted.
const RUNNING_VERSION = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version || null;
} catch {
return null;
}
})();
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
@@ -132,6 +145,8 @@ const wss = createWebSocketServer(server, {
shouldAutoOpenUrlFromOutput,
},
getPluginPort,
browserUseViewer: (ws, pathname) => browserUseService.handleViewerWebSocket(ws, pathname),
authenticateBrowserUseViewer: authenticateBrowserUseViewerPath,
});
// Make WebSocket server available to routes
@@ -156,7 +171,8 @@ app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
installMode
installMode,
version: RUNNING_VERSION
});
});
@@ -196,11 +212,42 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes);
function readCookieValue(header, name) {
if (!header) return null;
const prefix = `${name}=`;
const cookie = String(header).split(';').map((part) => part.trim()).find((part) => part.startsWith(prefix));
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;
}
function authenticateBrowserUseViewerPath(pathname, token) {
const parts = String(pathname || '').split('/');
const sessionId = parts[4];
if (parts[1] !== 'api' || parts[2] !== 'browser-use' || parts[3] !== 'sessions' || parts[5] !== 'viewer' || parts[6] !== 'websockify') {
return false;
}
return browserUseService.validateViewerToken(decodeURIComponent(sessionId), token);
}
function authenticateBrowserUse(req, res, next) {
const match = /^\/sessions\/([^/]+)\/viewer(?:\/|$)/.exec(req.path || '');
if (match) {
const sessionId = decodeURIComponent(match[1]);
const token = typeof req.query.viewerToken === 'string'
? req.query.viewerToken
: readCookieValue(req.headers.cookie, VIEWER_COOKIE_NAME);
if (browserUseService.validateViewerToken(sessionId, token)) {
return next();
}
return res.status(401).json({ error: 'Browser viewer access requires a valid session token.' });
}
return authenticateToken(req, res, next);
}
// Browser MCP bridge API (local token protected)
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
// Browser API Routes (protected)
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
app.use('/api/browser-use', authenticateBrowserUse, browserUseRoutes);
// Unified provider MCP routes (protected)
app.use('/api/providers', authenticateToken, providerRoutes);

View File

@@ -1,6 +1,7 @@
import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
import { VIEWER_COOKIE_NAME, VIEWER_TOKEN_TTL_MS } from '@/modules/browser-use/browser-use.viewer.js';
const router = express.Router();
@@ -8,6 +9,45 @@ function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || '';
}
const SAFE_VIEWER_ROOT_FILES = new Set(['vnc.html', 'favicon.ico', 'manifest.json']);
const SAFE_VIEWER_ROOT_DIRS = new Set(['app', 'core', 'vendor', 'assets', 'images', 'utils']);
function isSafeViewerPath(viewerPath: string): boolean {
if (!viewerPath || viewerPath.startsWith('/') || viewerPath.includes('..') || viewerPath.includes('\\')) {
return false;
}
if (!/^[A-Za-z0-9][A-Za-z0-9._~/-]*$/.test(viewerPath)) {
return false;
}
if (SAFE_VIEWER_ROOT_FILES.has(viewerPath)) {
return true;
}
const [rootDir] = viewerPath.split('/');
return Boolean(rootDir && SAFE_VIEWER_ROOT_DIRS.has(rootDir));
}
function isSecureRequest(req: express.Request): boolean {
const forwardedProto = String(req.headers['x-forwarded-proto'] || '')
.split(',')[0]
.trim()
.toLowerCase();
return req.secure || forwardedProto === 'https';
}
function readQueryString(originalUrl: string): string {
const queryIndex = originalUrl.indexOf('?');
if (queryIndex < 0) {
return '';
}
const params = new URLSearchParams(originalUrl.slice(queryIndex + 1));
params.delete('viewerToken');
const nextQuery = params.toString();
return nextQuery ? `?${nextQuery}` : '';
}
router.get('/status', async (_req, res) => {
try {
res.json({ success: true, data: await browserUseService.getStatus() });
@@ -62,13 +102,60 @@ router.get('/sessions', async (_req, res) => {
try {
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
} catch (error) {
res.status(401).json({
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
});
}
});
router.get('/sessions/:sessionId/viewer/*', async (req, res) => {
try {
const sessionId = readParam(req.params.sessionId);
const originalPath = req.originalUrl.split('?')[0] || '';
const viewerMarker = `/sessions/${sessionId}/viewer/`;
const markerIndex = originalPath.indexOf(viewerMarker);
const rawViewerPath = markerIndex >= 0 ? originalPath.slice(markerIndex + viewerMarker.length) : 'vnc.html';
const viewerPath = decodeURIComponent(rawViewerPath).replace(/^\/+/, '') || 'vnc.html';
if (!isSafeViewerPath(viewerPath)) {
res.status(400).json({ success: false, error: 'Invalid Browser viewer path.' });
return;
}
const viewerToken = readParam(req.query.viewerToken as string | string[] | undefined);
if (viewerPath === 'vnc.html' && browserUseService.validateViewerToken(sessionId, viewerToken)) {
res.cookie(VIEWER_COOKIE_NAME, viewerToken, {
httpOnly: true,
sameSite: 'lax',
secure: isSecureRequest(req),
maxAge: VIEWER_TOKEN_TTL_MS,
path: '/api/browser-use/sessions/' + encodeURIComponent(sessionId) + '/viewer',
});
}
const target = browserUseService.getViewerProxyTarget(sessionId);
const query = readQueryString(req.originalUrl);
const upstream = await fetch(`http://127.0.0.1:${target.websockifyPort}/${viewerPath}${query}`, {
headers: {
accept: String(req.headers.accept || '*/*'),
},
});
const contentType = upstream.headers.get('content-type');
if (contentType) {
res.setHeader('content-type', contentType);
}
const cacheControl = viewerPath === 'vnc.html' ? 'no-store' : 'public, max-age=3600';
res.setHeader('cache-control', cacheControl);
res.status(upstream.status);
const body = Buffer.from(await upstream.arrayBuffer());
res.send(body);
} catch (error) {
res.status(404).json({
success: false,
error: error instanceof Error ? error.message : 'Browser viewer is not available.',
});
}
});
router.post('/sessions/:sessionId/stop', async (req, res) => {
try {
const result = await browserUseService.stopSession(readParam(req.params.sessionId));

View File

@@ -1,128 +1,86 @@
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js';
import { WebSocket } from 'ws';
import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js';
import {
getOrCreateMcpToken,
getProfilePath,
normalizeBrowserBackend,
PROFILE_ROOT,
readSettings,
resolveSessionProfileName,
useVisibleCamoufoxBackend,
writeSettings,
} from './browser-use.settings.js';
import type {
BrowserUseSession,
BrowserUseSettings,
PublicBrowserUseSession,
RuntimeHandle,
RuntimeProbe,
RuntimeReadiness,
} from './browser-use.types.js';
import { getViewerUrl, handleViewerWebSocket, VIEWER_TOKEN_TTL_MS } from './browser-use.viewer.js';
const require = createRequire(import.meta.url);
const __dirname = getModuleDir(import.meta.url);
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
type BrowserUseRuntime = 'cloud' | 'local';
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
type BrowserUseSession = {
id: string;
ownerId: string;
createdBy: 'agent';
runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus;
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
type RuntimeHandle = {
browser?: any;
context?: any;
page?: any;
};
type BrowserUseSettings = {
enabled: boolean;
};
type RuntimeReadiness = {
playwright: any | null;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
chromiumExecutablePath: string | null;
installInProgress: boolean;
installMessage: string | null;
};
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
const sessions = new Map<string, BrowserUseSession>();
const handles = new Map<string, RuntimeHandle>();
const reservedDisplays = new Set<string>();
const viewerTokens = new Map<string, { token: string; expiresAt: number }>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null;
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
const DEFAULT_SETTINGS: BrowserUseSettings = {
enabled: false,
};
const AGENT_OWNER_ID = 'agent';
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
const MCP_SERVER_NAME = 'cloudcli-browser';
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
const VISIBLE_BROWSER_ENABLED = process.env.CLOUDCLI_BROWSER_USE_VISIBLE !== 'false';
const RUNTIME_ROOT = process.env.CLOUDCLI_BROWSER_USE_RUNTIME_ROOT || '/opt/claudecodeui/.runtime-browser';
const NOVNC_ROOT = process.env.CLOUDCLI_BROWSER_USE_NOVNC_ROOT || path.join(RUNTIME_ROOT, 'novnc');
const X11VNC_BIN = process.env.CLOUDCLI_BROWSER_USE_X11VNC_BIN || path.join(RUNTIME_ROOT, 'rootfs/usr/bin/x11vnc');
const X11VNC_LIB_DIR = process.env.CLOUDCLI_BROWSER_USE_X11VNC_LIB_DIR || path.join(RUNTIME_ROOT, 'rootfs/usr/lib/x86_64-linux-gnu');
const X11VNC_EXTRA_LIB_DIR = process.env.CLOUDCLI_BROWSER_USE_X11VNC_EXTRA_LIB_DIR || path.join(RUNTIME_ROOT, 'rootfs/lib/x86_64-linux-gnu');
const LOG_RUNTIME_PROCESS_OUTPUT = process.env.CLOUDCLI_BROWSER_USE_RUNTIME_LOGS === 'true';
function getRuntime(): BrowserUseRuntime {
function getRuntime(): 'cloud' | 'local' {
return IS_PLATFORM ? 'cloud' : 'local';
}
function readSettings(): BrowserUseSettings {
function getCamoufoxExecutablePath(): string | null {
const configured = process.env.CLOUDCLI_BROWSER_USE_CAMOUFOX_EXECUTABLE;
if (configured && fs.existsSync(configured)) {
return configured;
}
try {
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
if (!raw) {
return DEFAULT_SETTINGS;
}
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return {
enabled: parsed.enabled === true,
};
} catch (error: any) {
console.warn('[Browser] Failed to read settings:', error?.message || error);
return DEFAULT_SETTINGS;
const output = execFileSync(path.join(os.homedir(), '.local/bin/camoufox'), ['path'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
const executablePath = fs.statSync(output).isDirectory()
? path.join(output, 'camoufox')
: output;
return fs.existsSync(executablePath) ? executablePath : null;
} catch {
return null;
}
}
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = {
enabled: settings.enabled === true,
};
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
return normalized;
}
function getOrCreateMcpToken(): string {
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
if (existing) {
return existing;
}
const token = randomBytes(32).toString('hex');
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
return token;
}
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) {
return 'Browser is disabled in settings.';
@@ -132,6 +90,26 @@ function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadine
return 'Install Playwright and Chromium to use browser sessions.';
}
if (settings.browserBackend === 'camoufox-vnc' && !getCamoufoxExecutablePath()) {
return 'Camoufox is selected, but Camoufox is not installed.';
}
if (useVisibleCamoufoxBackend(settings)) {
if (!VISIBLE_BROWSER_ENABLED) {
return 'Camoufox is selected, but visible browser sessions are disabled.';
}
if (!getCamoufoxExecutablePath()) {
return 'Camoufox is selected, but Camoufox is not installed.';
}
if (!fs.existsSync(X11VNC_BIN)) {
return 'Camoufox is selected, but x11vnc is missing.';
}
if (!fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'))) {
return 'Camoufox is selected, but noVNC is missing.';
}
return readiness.installMessage || 'Camoufox runtime is not ready.';
}
if (!readiness.chromiumInstalled) {
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
}
@@ -176,24 +154,6 @@ async function removeMcpServerFromAllProviders(name: string) {
return results.map((result) => ({ ...result, name }));
}
function normalizeProfileName(profileName?: string | null): string | null {
const normalized = String(profileName || '').trim();
if (!normalized) {
return null;
}
return normalized.slice(0, 80);
}
function getProfilePath(profileName: string): string {
const safeName = profileName
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'default';
return path.join(PROFILE_ROOT, safeName);
}
function probeRuntime(): RuntimeProbe {
const playwright = getPlaywright();
const readiness: RuntimeProbe = {
@@ -238,6 +198,175 @@ function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadines
};
}
function findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => {
if (typeof address === 'object' && address?.port) {
resolve(address.port);
} else {
reject(new Error('Failed to reserve a browser runtime port.'));
}
});
});
});
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRuntimeProcessAlive(child: ReturnType<typeof spawn>): boolean {
return child.exitCode === null && child.signalCode === null && !child.killed;
}
function assertRuntimeProcessesAlive(processes: Array<ReturnType<typeof spawn>>, label: string) {
const exited = processes.find((child) => !isRuntimeProcessAlive(child));
if (exited) {
throw new Error(`${label} exited before the Browser viewer runtime was ready.`);
}
}
async function isPortListening(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
let settled = false;
const finish = (listening: boolean) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
resolve(listening);
};
socket.setTimeout(250);
socket.once('connect', () => finish(true));
socket.once('timeout', () => finish(false));
socket.once('error', () => finish(false));
});
}
async function waitForRuntimePort(
port: number,
label: string,
processes: Array<ReturnType<typeof spawn>>,
timeoutMs = 5_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
assertRuntimeProcessesAlive(processes, label);
if (await isPortListening(port)) {
return;
}
await delay(100);
}
assertRuntimeProcessesAlive(processes, label);
throw new Error(`${label} did not start listening on 127.0.0.1:${port}.`);
}
function killRuntimeProcesses(processes?: Array<ReturnType<typeof spawn>>) {
processes?.forEach((child) => child.kill('SIGTERM'));
}
function reserveDisplay(): string {
for (let index = 90; index < 140; index += 1) {
const display = `:${index}`;
if (!reservedDisplays.has(display)) {
reservedDisplays.add(display);
return display;
}
}
throw new Error('No browser display slots are available.');
}
function spawnRuntimeProcess(command: string, args: string[], options: { env?: NodeJS.ProcessEnv } = {}) {
const child = spawn(command, args, {
env: { ...process.env, ...options.env },
stdio: ['ignore', 'ignore', 'pipe'],
});
child.stderr?.on('data', (chunk) => {
if (!LOG_RUNTIME_PROCESS_OUTPUT) {
return;
}
const text = String(chunk).trim();
if (text) {
console.warn(`[Browser runtime] ${path.basename(command)}: ${text}`);
}
});
child.on('error', (error) => {
console.warn(`[Browser runtime] ${path.basename(command)} failed:`, error.message);
});
return child;
}
async function startVisibleRuntime(): Promise<NonNullable<RuntimeHandle['viewer']> & { processes: Array<ReturnType<typeof spawn>> }> {
const display = reserveDisplay();
const vncPort = await findAvailablePort();
const websockifyPort = await findAvailablePort();
const processes: Array<ReturnType<typeof spawn>> = [];
try {
processes.push(spawnRuntimeProcess('Xvfb', [
display,
'-screen',
'0',
'1440x900x24',
'-ac',
'-nolisten',
'tcp',
]));
await delay(700);
assertRuntimeProcessesAlive(processes, 'Xvfb');
if (!fs.existsSync(X11VNC_BIN)) {
throw new Error(`x11vnc is missing at ${X11VNC_BIN}.`);
}
processes.push(spawnRuntimeProcess(X11VNC_BIN, [
'-display',
display,
'-localhost',
'-forever',
'-shared',
'-rfbport',
String(vncPort),
'-nopw',
'-quiet',
], {
env: {
LD_LIBRARY_PATH: `${X11VNC_LIB_DIR}:${X11VNC_EXTRA_LIB_DIR}:${process.env.LD_LIBRARY_PATH || ''}`,
},
}));
await waitForRuntimePort(vncPort, 'x11vnc', processes);
if (!fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'))) {
throw new Error(`noVNC is missing at ${NOVNC_ROOT}.`);
}
processes.push(spawnRuntimeProcess(path.join(os.homedir(), '.local/bin/websockify'), [
'--web',
NOVNC_ROOT,
`127.0.0.1:${websockifyPort}`,
`127.0.0.1:${vncPort}`,
]));
await waitForRuntimePort(websockifyPort, 'websockify', processes);
return {
display,
vncPort,
websockifyPort,
noVncRoot: NOVNC_ROOT,
processes,
};
} catch (error) {
killRuntimeProcesses(processes);
reservedDisplays.delete(display);
throw error;
}
}
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
10,
@@ -350,6 +479,45 @@ function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
return publicFields;
}
function getSessionViewer(sessionId: string): RuntimeHandle['viewer'] | null {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID || session.status !== 'ready') {
return null;
}
return handles.get(sessionId)?.viewer || null;
}
function createViewerToken(sessionId: string): string {
const token = randomUUID();
viewerTokens.set(sessionId, {
token,
expiresAt: Date.now() + VIEWER_TOKEN_TTL_MS,
});
return token;
}
function deleteViewerToken(sessionId: string) {
viewerTokens.delete(sessionId);
}
function validateViewerTokenForSession(sessionId: string, token: string | null | undefined): boolean {
if (!token) {
return false;
}
const session = sessions.get(sessionId);
const viewer = session?.ownerId === AGENT_OWNER_ID && session.status === 'ready'
? handles.get(sessionId)?.viewer || null
: null;
const stored = viewerTokens.get(sessionId);
if (!viewer || !stored || stored.token !== token || stored.expiresAt < Date.now()) {
if (stored?.expiresAt && stored.expiresAt < Date.now()) {
viewerTokens.delete(sessionId);
}
return false;
}
return true;
}
function ownerSessions(ownerId: string): BrowserUseSession[] {
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
}
@@ -357,8 +525,13 @@ function ownerSessions(ownerId: string): BrowserUseSession[] {
async function closeHandle(sessionId: string): Promise<void> {
const handle = handles.get(sessionId);
handles.delete(sessionId);
deleteViewerToken(sessionId);
await handle?.context?.close?.().catch(() => undefined);
await handle?.browser?.close().catch(() => undefined);
killRuntimeProcesses(handle?.processes);
if (handle?.viewer?.display) {
reservedDisplays.delete(handle.viewer.display);
}
}
async function expireStaleSessions(now = Date.now()): Promise<void> {
@@ -424,6 +597,11 @@ export const browserUseService = {
const current = readSettings();
const nextSettings = {
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
persistSessions: typeof settings.persistSessions === 'boolean' ? settings.persistSessions : current.persistSessions,
defaultProfileName: typeof settings.defaultProfileName === 'string'
? settings.defaultProfileName
: current.defaultProfileName,
browserBackend: settings.browserBackend ? normalizeBrowserBackend(settings.browserBackend) : current.browserBackend,
};
const next = writeSettings(nextSettings);
@@ -439,14 +617,28 @@ export const browserUseService = {
async getStatus() {
const settings = readSettings();
const readiness = getRuntimeReadiness();
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
const useVisibleBackend = useVisibleCamoufoxBackend(settings);
const visibleCamoufoxReady = useVisibleBackend
&& VISIBLE_BROWSER_ENABLED
&& readiness.playwrightInstalled
&& Boolean(getCamoufoxExecutablePath())
&& fs.existsSync(X11VNC_BIN)
&& fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'));
const available = settings.enabled
&& readiness.playwrightInstalled
&& (useVisibleBackend ? visibleCamoufoxReady : readiness.chromiumInstalled);
return {
enabled: settings.enabled,
runtime: getRuntime(),
backend: useVisibleBackend ? 'camoufox-vnc' : 'playwright',
browserBackend: settings.browserBackend,
available,
playwrightInstalled: readiness.playwrightInstalled,
chromiumInstalled: readiness.chromiumInstalled,
camoufoxInstalled: Boolean(getCamoufoxExecutablePath()),
noVncInstalled: fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html')),
x11vncInstalled: fs.existsSync(X11VNC_BIN),
installInProgress: readiness.installInProgress,
sessionCount: sessions.size,
message: available
@@ -505,7 +697,7 @@ export const browserUseService = {
}
await expireStaleSessions();
const profileName = normalizeProfileName(options?.profileName);
const profileName = resolveSessionProfileName(settings, options?.profileName);
const now = new Date().toISOString();
const session: BrowserUseSession = {
@@ -521,6 +713,9 @@ export const browserUseService = {
updatedAt: now,
lastAction: 'create',
message: null,
backend: useVisibleCamoufoxBackend(settings) ? 'camoufox-vnc' : 'playwright',
viewerUrl: null,
viewerEmbedUrl: null,
profileName,
viewport: { width: 1440, height: 900 },
cursor: null,
@@ -532,7 +727,13 @@ export const browserUseService = {
}
const readiness = getRuntimeReadiness();
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
const useVisibleBackend = useVisibleCamoufoxBackend(settings);
const visibleCamoufoxReady = useVisibleBackend
&& VISIBLE_BROWSER_ENABLED
&& Boolean(getCamoufoxExecutablePath())
&& fs.existsSync(X11VNC_BIN)
&& fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'));
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.playwright || (useVisibleBackend ? !visibleCamoufoxReady : !readiness.chromiumInstalled)) {
session.message = getSetupMessage(settings, readiness);
sessions.set(session.id, session);
return publicSession(session);
@@ -541,31 +742,73 @@ export const browserUseService = {
let browser: any | undefined;
let context: any | undefined;
let page: any;
const launchOptions = {
headless: true,
let viewer: RuntimeHandle['viewer'];
let processes: RuntimeHandle['processes'];
const launchOptions: Record<string, unknown> = {
headless: !useVisibleBackend,
args: ['--disable-dev-shm-usage'],
};
const contextOptions = {
viewport: { width: 1440, height: 900 },
serviceWorkers: 'block',
};
const contextOptions = useVisibleBackend
? { viewport: null }
: {
viewport: { width: 1440, height: 900 },
serviceWorkers: 'block',
};
if (profileName) {
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
...launchOptions,
...contextOptions,
});
page = context.pages()[0] || await context.newPage();
} else {
browser = await readiness.playwright.chromium.launch(launchOptions);
context = await browser.newContext(contextOptions);
page = await context.newPage();
try {
if (useVisibleBackend) {
const camoufoxExecutable = getCamoufoxExecutablePath();
if (!camoufoxExecutable) {
throw new Error('Camoufox is not installed.');
}
const runtime = await startVisibleRuntime();
viewer = {
display: runtime.display,
vncPort: runtime.vncPort,
websockifyPort: runtime.websockifyPort,
noVncRoot: runtime.noVncRoot,
};
processes = runtime.processes;
launchOptions.executablePath = camoufoxExecutable;
launchOptions.env = {
...process.env,
DISPLAY: runtime.display,
LD_LIBRARY_PATH: `${X11VNC_LIB_DIR}:${X11VNC_EXTRA_LIB_DIR}:${process.env.LD_LIBRARY_PATH || ''}`,
};
launchOptions.args = [];
session.backend = 'camoufox-vnc';
const viewerToken = createViewerToken(session.id);
session.viewerUrl = getViewerUrl(session.id, viewerToken);
session.viewerEmbedUrl = session.viewerUrl;
}
if (profileName) {
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
const browserType = useVisibleBackend ? readiness.playwright.firefox : readiness.playwright.chromium;
context = await browserType.launchPersistentContext(getProfilePath(profileName), {
...launchOptions,
...contextOptions,
});
page = context.pages()[0] || await context.newPage();
} else {
const browserType = useVisibleBackend ? readiness.playwright.firefox : readiness.playwright.chromium;
browser = await browserType.launch(launchOptions);
context = await browser.newContext(contextOptions);
page = await context.newPage();
}
} catch (error) {
await context?.close?.().catch(() => undefined);
await browser?.close?.().catch(() => undefined);
killRuntimeProcesses(processes);
if (viewer?.display) {
reservedDisplays.delete(viewer.display);
}
throw error;
}
session.status = 'ready';
session.message = 'Browser session is ready.';
sessions.set(session.id, session);
handles.set(session.id, { browser, context, page });
handles.set(session.id, { browser, context, page, processes, viewer });
await captureSession(session, page);
return publicSession(session);
},
@@ -812,6 +1055,25 @@ export const browserUseService = {
return { deleted: true, sessionId };
},
getViewerProxyTarget(sessionId: string) {
const viewer = getSessionViewer(sessionId);
if (!viewer) {
throw new Error('Browser viewer is not available for this session.');
}
return {
websockifyPort: viewer.websockifyPort,
noVncRoot: viewer.noVncRoot,
};
},
validateViewerToken(sessionId: string, token: string | null | undefined) {
return validateViewerTokenForSession(sessionId, token);
},
handleViewerWebSocket(clientWs: WebSocket, pathname: string) {
handleViewerWebSocket(clientWs, pathname, getSessionViewer);
},
async agentStopSession(sessionId: string) {
await this.getAgentSession(sessionId);
return this.stopSession(sessionId);

View File

@@ -0,0 +1,147 @@
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js';
import type { BrowserUseBackend, BrowserUseSettings } from './browser-use.types.js';
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
const MAX_PROFILE_NAME_LENGTH = 80;
export const DEFAULT_BROWSER_USE_SETTINGS: BrowserUseSettings = {
enabled: false,
persistSessions: false,
defaultProfileName: 'default',
browserBackend: IS_PLATFORM ? 'camoufox-vnc' : 'playwright',
};
export const PROFILE_ROOT = process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT
|| path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
export function normalizeBrowserBackend(value: unknown): BrowserUseBackend {
return value === 'playwright' || value === 'camoufox-vnc'
? value
: DEFAULT_BROWSER_USE_SETTINGS.browserBackend;
}
function trimEdgeDashes(value: string): string {
let start = 0;
let end = value.length;
while (start < end && value[start] === '-') {
start += 1;
}
while (end > start && value[end - 1] === '-') {
end -= 1;
}
return value.slice(start, end);
}
export function normalizeProfileName(profileName?: string | null): string | null {
const sanitized = trimEdgeDashes(String(profileName || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-'));
const normalized = sanitized
.slice(0, MAX_PROFILE_NAME_LENGTH)
.replace(/^-+|-+$/g, '');
if (!normalized) {
return null;
}
return /[a-z0-9]/.test(normalized) ? normalized : null;
}
export function normalizeDefaultProfileName(profileName?: string | null): string {
return normalizeProfileName(profileName) || DEFAULT_BROWSER_USE_SETTINGS.defaultProfileName;
}
export function resolveSessionProfileName(settings: BrowserUseSettings, profileName?: string | null): string | null {
const requestedProfileName = normalizeProfileName(profileName);
if (String(profileName || '').trim() && !requestedProfileName) {
throw new Error('Browser profile name must include at least one letter or number.');
}
if (requestedProfileName) {
validateRequestedProfileName(profileName, requestedProfileName);
return requestedProfileName;
}
return settings.persistSessions ? normalizeDefaultProfileName(settings.defaultProfileName) : null;
}
export function getProfilePath(profileName: string): string {
return path.join(PROFILE_ROOT, normalizeDefaultProfileName(profileName));
}
function validateRequestedProfileName(profileName: string | null | undefined, normalizedProfileName: string): void {
const requestedProfileName = String(profileName || '').trim();
const existingProfileName = findExistingProfileName(normalizedProfileName);
if (
existingProfileName
&& (requestedProfileName !== normalizedProfileName || existingProfileName !== normalizedProfileName)
) {
throw new Error(`Browser profile "${requestedProfileName}" resolves to existing profile "${existingProfileName}". Use "${normalizedProfileName}" instead.`);
}
}
function findExistingProfileName(normalizedProfileName: string): string | null {
try {
if (!fs.existsSync(PROFILE_ROOT)) {
return null;
}
const entries = fs.readdirSync(PROFILE_ROOT, { withFileTypes: true });
const match = entries.find((entry) => entry.isDirectory() && normalizeProfileName(entry.name) === normalizedProfileName);
return match?.name || null;
} catch {
return null;
}
}
export function useVisibleCamoufoxBackend(settings: BrowserUseSettings): boolean {
return settings.browserBackend === 'camoufox-vnc';
}
export function readSettings(): BrowserUseSettings {
try {
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
if (!raw) {
return DEFAULT_BROWSER_USE_SETTINGS;
}
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return {
enabled: parsed.enabled === true,
persistSessions: parsed.persistSessions === true,
defaultProfileName: normalizeDefaultProfileName(parsed.defaultProfileName),
browserBackend: normalizeBrowserBackend(parsed.browserBackend),
};
} catch (error: any) {
console.warn('[Browser] Failed to read settings:', error?.message || error);
return DEFAULT_BROWSER_USE_SETTINGS;
}
}
export function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = {
enabled: settings.enabled === true,
persistSessions: settings.persistSessions === true,
defaultProfileName: normalizeDefaultProfileName(settings.defaultProfileName),
browserBackend: normalizeBrowserBackend(settings.browserBackend),
};
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
return normalized;
}
export function getOrCreateMcpToken(): string {
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
if (existing) {
return existing;
}
const token = randomBytes(32).toString('hex');
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
return token;
}

View File

@@ -0,0 +1,66 @@
import type { spawn } from 'node:child_process';
export type BrowserUseRuntime = 'cloud' | 'local';
export type BrowserUseBackend = 'playwright' | 'camoufox-vnc';
export type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
export type BrowserUseSession = {
id: string;
ownerId: string;
createdBy: 'agent';
runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus;
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
backend: BrowserUseBackend;
viewerUrl: string | null;
viewerEmbedUrl: string | null;
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
export type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
export type RuntimeHandle = {
browser?: any;
context?: any;
page?: any;
processes?: Array<ReturnType<typeof spawn>>;
viewer?: {
display: string;
vncPort: number;
websockifyPort: number;
noVncRoot: string;
};
};
export type BrowserUseSettings = {
enabled: boolean;
persistSessions: boolean;
defaultProfileName: string;
browserBackend: BrowserUseBackend;
};
export type RuntimeReadiness = {
playwright: any | null;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
chromiumExecutablePath: string | null;
installInProgress: boolean;
installMessage: string | null;
};
export type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;

View File

@@ -0,0 +1,76 @@
import { WebSocket } from 'ws';
import type { RuntimeHandle } from './browser-use.types.js';
type BrowserUseViewer = NonNullable<RuntimeHandle['viewer']>;
export const VIEWER_COOKIE_NAME = 'browser_use_viewer_token';
const DEFAULT_VIEWER_TOKEN_TTL_MS = 30 * 60 * 1000;
const parsedViewerTokenTtlMs = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_VIEWER_TOKEN_TTL_MS || String(DEFAULT_VIEWER_TOKEN_TTL_MS),
10,
);
export const VIEWER_TOKEN_TTL_MS =
Number.isFinite(parsedViewerTokenTtlMs) && parsedViewerTokenTtlMs > 0
? parsedViewerTokenTtlMs
: DEFAULT_VIEWER_TOKEN_TTL_MS;
export function getViewerUrl(sessionId: string, viewerToken?: string): string {
const basePath = `/api/browser-use/sessions/${encodeURIComponent(sessionId)}/viewer`;
const websockifyPath = viewerToken
? `${basePath}/websockify?viewerToken=${encodeURIComponent(viewerToken)}`
: `${basePath}/websockify`;
const params = new URLSearchParams({
autoconnect: '1',
resize: 'scale',
reconnect: '1',
path: websockifyPath,
});
if (viewerToken) {
params.set('viewerToken', viewerToken);
}
return `${basePath}/vnc.html?${params.toString()}`;
}
export function handleViewerWebSocket(
clientWs: WebSocket,
pathname: string,
getSessionViewer: (sessionId: string) => BrowserUseViewer | null | undefined,
) {
const match = /^\/api\/browser-use\/sessions\/([^/]+)\/viewer\/websockify\/?$/.exec(pathname);
const sessionId = match ? decodeURIComponent(match[1]) : '';
const viewer = sessionId ? getSessionViewer(sessionId) : null;
if (!viewer) {
clientWs.close(4404, 'Browser viewer not found');
return;
}
const upstream = new WebSocket(`ws://127.0.0.1:${viewer.websockifyPort}`);
upstream.on('open', () => {
clientWs.on('message', (data) => {
if (upstream.readyState === WebSocket.OPEN) {
upstream.send(data);
}
});
upstream.on('message', (data) => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data);
}
});
});
upstream.on('close', (code, reason) => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.close(code, reason);
}
});
upstream.on('error', () => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.close(4502, 'Browser viewer upstream error');
}
});
clientWs.on('close', () => {
if (upstream.readyState === WebSocket.OPEN) {
upstream.close();
}
});
}

View File

@@ -0,0 +1,2 @@
export { browserUseService } from './browser-use.service.js';
export { VIEWER_COOKIE_NAME } from './browser-use.viewer.js';

View File

@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
const originalProfileRoot = process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT;
const testProfileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-use-profiles-'));
process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT = testProfileRoot;
const {
getProfilePath,
normalizeDefaultProfileName,
normalizeProfileName,
PROFILE_ROOT,
resolveSessionProfileName,
} = await import('@/modules/browser-use/browser-use.settings.js');
test.after(() => {
if (originalProfileRoot === undefined) {
delete process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT;
} else {
process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT = originalProfileRoot;
}
fs.rmSync(testProfileRoot, { recursive: true, force: true });
});
test('browser profile names are canonicalized before storage and path resolution', () => {
assert.equal(normalizeProfileName(' Work Profile!! '), 'work-profile');
assert.equal(normalizeProfileName(`${'-'.repeat(100)}Work Profile`), 'work-profile');
assert.equal(normalizeDefaultProfileName(' Work Profile!! '), 'work-profile');
assert.equal(
getProfilePath(' Work Profile!! '),
`${PROFILE_ROOT}/work-profile`,
);
assert.equal(
resolveSessionProfileName({
enabled: true,
persistSessions: true,
defaultProfileName: ' Work Profile!! ',
browserBackend: 'playwright',
}),
'work-profile',
);
});
test('browser profile aliases are rejected when the normalized profile already exists', () => {
const profileName = `alias-test-${Date.now()}`;
fs.mkdirSync(getProfilePath(profileName), { recursive: true });
try {
assert.throws(
() => resolveSessionProfileName({
enabled: true,
persistSessions: false,
defaultProfileName: 'default',
browserBackend: 'playwright',
}, profileName.toUpperCase()),
/resolves to existing profile/,
);
assert.equal(
resolveSessionProfileName({
enabled: true,
persistSessions: false,
defaultProfileName: 'default',
browserBackend: 'playwright',
}, profileName),
profileName,
);
} finally {
fs.rmSync(getProfilePath(profileName), { recursive: true, force: true });
}
});

View File

@@ -99,6 +99,14 @@ export class ClaudeSkillsProvider extends SkillsProvider {
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(getClaudeHomePath(), 'skills'),
commandPrefix: '/',
};
}
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
const enabledPlugins = readObjectRecord(settings.enabledPlugins);

View File

@@ -57,4 +57,12 @@ export class CodexSkillsProvider extends SkillsProvider {
return sources;
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.agents', 'skills'),
commandPrefix: '$',
};
}
}

View File

@@ -28,4 +28,12 @@ export class CursorSkillsProvider extends SkillsProvider {
},
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.cursor', 'skills'),
commandPrefix: '/',
};
}
}

View File

@@ -33,4 +33,12 @@ export class GeminiSkillsProvider extends SkillsProvider {
},
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
commandPrefix: '/',
};
}
}

View File

@@ -12,6 +12,8 @@ import type {
McpScope,
McpTransport,
ProviderChangeActiveModelInput,
ProviderSkillCreateFile,
ProviderSkillCreateInput,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
@@ -179,6 +181,104 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
};
};
const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateInput => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const rawEntries = Array.isArray(body.entries)
? body.entries
: typeof body.content === 'string'
? [{
content: body.content,
directoryName: body.directoryName,
fileName: body.fileName,
files: body.files,
}]
: null;
if (!rawEntries || rawEntries.length === 0) {
throw new AppError('At least one skill entry is required.', {
code: 'PROVIDER_SKILLS_REQUIRED',
statusCode: 400,
});
}
const entries = rawEntries.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
throw new AppError(`Skill entry ${index + 1} must be an object.`, {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const record = entry as Record<string, unknown>;
const content = typeof record.content === 'string' ? record.content : '';
const directoryName = readOptionalQueryString(record.directoryName);
const fileName = readOptionalQueryString(record.fileName);
const rawFiles = record.files;
if (!content.trim()) {
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
statusCode: 400,
});
}
if (rawFiles !== undefined && !Array.isArray(rawFiles)) {
throw new AppError(`Skill entry ${index + 1} files must be an array.`, {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const files: ProviderSkillCreateFile[] | undefined = rawFiles?.map((file, fileIndex) => {
if (!file || typeof file !== 'object') {
throw new AppError(`Skill entry ${index + 1} file ${fileIndex + 1} must be an object.`, {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const fileRecord = file as Record<string, unknown>;
const relativePath = readOptionalQueryString(fileRecord.relativePath);
const fileContent = typeof fileRecord.content === 'string' ? fileRecord.content : null;
const encoding = fileRecord.encoding === 'utf8' || fileRecord.encoding === 'base64'
? fileRecord.encoding
: null;
if (!relativePath || fileContent === null || !encoding) {
throw new AppError(
`Skill entry ${index + 1} file ${fileIndex + 1} requires relativePath, content, and encoding.`,
{
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
},
);
}
return {
relativePath,
content: fileContent,
encoding,
};
});
return {
content,
directoryName,
fileName,
files,
};
});
return { entries };
};
const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value);
if (
@@ -320,6 +420,16 @@ router.get(
}),
);
router.post(
'/:provider/skills',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const input = parseProviderSkillCreatePayload(req.body);
const skills = await providerSkillsService.addProviderSkills(provider, input);
res.json(createApiSuccessResponse({ provider, skills }));
}),
);
// ----------------- MCP routes -----------------
router.get(
'/:provider/mcp/servers',

View File

@@ -1,5 +1,9 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { ProviderSkill, ProviderSkillListOptions } from '@/shared/types.js';
import type {
ProviderSkill,
ProviderSkillCreateInput,
ProviderSkillListOptions,
} from '@/shared/types.js';
export const providerSkillsService = {
/**
@@ -12,4 +16,15 @@ export const providerSkillsService = {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.listSkills(options);
},
/**
* Writes one or more global skills for one provider.
*/
async addProviderSkills(
providerName: string,
input: ProviderSkillCreateInput,
): Promise<ProviderSkill[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.addSkills(input);
},
};

View File

@@ -1,20 +1,86 @@
import path from 'node:path';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import type { IProviderSkills } from '@/shared/interfaces.js';
import type {
LLMProvider,
ProviderSkillCreateInput,
ProviderSkill,
ProviderSkillListOptions,
ProviderSkillSource,
} from '@/shared/types.js';
import {
findProviderSkillMarkdownFiles,
readOptionalString,
readProviderSkillMarkdownDefinitionFromContent,
readProviderSkillMarkdownDefinition,
AppError,
} from '@/shared/utils.js';
const resolveWorkspacePath = (workspacePath?: string): string =>
path.resolve(workspacePath ?? process.cwd());
const stripMarkdownExtension = (value: string): string => value.replace(/\.md$/i, '');
const normalizeSkillDirectoryName = (value: string): string => (
value
.trim()
.replace(/[\\/]+/g, '-')
.replace(/[<>:"|?*\x00-\x1F]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^\.+|\.+$/g, '')
.replace(/^-+|-+$/g, '')
);
type PendingSkillInstall = {
skillDirectoryPath: string;
skillPath: string;
content: string;
supportingFiles: Array<{
targetPath: string;
content: string | Buffer;
}>;
skill: ProviderSkill;
};
const resolveSkillSupportingFilePath = (
skillDirectoryPath: string,
relativePath: string,
entryIndex: number,
): string => {
const normalizedRelativePath = relativePath.trim().replace(/\\/g, '/');
const pathSegments = normalizedRelativePath.split('/');
if (
!normalizedRelativePath
|| path.isAbsolute(normalizedRelativePath)
|| pathSegments.some((segment) => !segment || segment === '.' || segment === '..')
|| normalizedRelativePath.toLowerCase() === 'skill.md'
) {
throw new AppError(
`Skill entry ${entryIndex + 1} includes an invalid supporting file path "${relativePath}".`,
{
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
statusCode: 400,
},
);
}
const resolvedSkillDirectoryPath = path.resolve(skillDirectoryPath);
const resolvedFilePath = path.resolve(resolvedSkillDirectoryPath, ...pathSegments);
if (!resolvedFilePath.startsWith(`${resolvedSkillDirectoryPath}${path.sep}`)) {
throw new AppError(
`Skill entry ${entryIndex + 1} supporting files must stay inside the skill directory.`,
{
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
statusCode: 400,
},
);
}
return resolvedFilePath;
};
/**
* Shared skills provider for provider-specific skill source discovery.
*/
@@ -60,5 +126,119 @@ export abstract class SkillsProvider implements IProviderSkills {
return skills;
}
async addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]> {
const globalSkillSource = await this.getGlobalSkillSource();
if (!globalSkillSource) {
throw new AppError(`${this.provider} does not support managed global skills.`, {
code: 'PROVIDER_SKILLS_WRITE_UNSUPPORTED',
statusCode: 400,
});
}
if (!Array.isArray(input.entries) || input.entries.length === 0) {
throw new AppError('At least one skill entry is required.', {
code: 'PROVIDER_SKILLS_REQUIRED',
statusCode: 400,
});
}
const seenSkillPaths = new Set<string>();
const pendingInstalls: PendingSkillInstall[] = [];
for (const [index, entry] of input.entries.entries()) {
const content = typeof entry.content === 'string' ? entry.content.trim() : '';
if (!content) {
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
statusCode: 400,
});
}
const fileNameFallback = readOptionalString(entry.fileName);
const requestedDirectoryName = readOptionalString(entry.directoryName);
const fallbackSkillName = normalizeSkillDirectoryName(
requestedDirectoryName
?? (fileNameFallback ? stripMarkdownExtension(fileNameFallback) : `skill-${index + 1}`),
);
const definition = readProviderSkillMarkdownDefinitionFromContent(content, fallbackSkillName);
const resolvedDirectoryName = normalizeSkillDirectoryName(
requestedDirectoryName ?? definition.name,
);
if (!resolvedDirectoryName) {
throw new AppError(`Skill entry ${index + 1} must include a valid skill name.`, {
code: 'PROVIDER_SKILL_NAME_REQUIRED',
statusCode: 400,
});
}
const skillDirectoryPath = path.join(globalSkillSource.rootDir, resolvedDirectoryName);
const skillPath = path.join(skillDirectoryPath, 'SKILL.md');
const normalizedSkillPath = path.resolve(skillPath);
if (seenSkillPaths.has(normalizedSkillPath)) {
throw new AppError(`Duplicate skill target "${resolvedDirectoryName}" in one request.`, {
code: 'PROVIDER_SKILL_DUPLICATE_TARGET',
statusCode: 400,
});
}
seenSkillPaths.add(normalizedSkillPath);
const supportingFiles = (entry.files ?? []).map((file) => ({
targetPath: resolveSkillSupportingFilePath(skillDirectoryPath, file.relativePath, index),
content: file.encoding === 'base64'
? Buffer.from(file.content, 'base64')
: file.content,
}));
const seenSupportingPaths = new Set<string>();
for (const file of supportingFiles) {
if (seenSupportingPaths.has(file.targetPath)) {
throw new AppError(`Skill entry ${index + 1} includes a duplicate supporting file path.`, {
code: 'PROVIDER_SKILL_DUPLICATE_FILE',
statusCode: 400,
});
}
seenSupportingPaths.add(file.targetPath);
}
const command = globalSkillSource.commandForSkill
? globalSkillSource.commandForSkill(definition.name)
: `${globalSkillSource.commandPrefix ?? '/'}${definition.name}`;
pendingInstalls.push({
skillDirectoryPath,
skillPath,
content,
supportingFiles,
skill: {
provider: this.provider,
name: definition.name,
description: definition.description,
command,
scope: globalSkillSource.scope,
sourcePath: skillPath,
pluginName: globalSkillSource.pluginName,
pluginId: globalSkillSource.pluginId,
},
});
}
for (const install of pendingInstalls) {
// Replace the complete skill directory so removed scripts or assets do not remain stale.
await rm(install.skillDirectoryPath, { recursive: true, force: true });
await mkdir(install.skillDirectoryPath, { recursive: true });
await writeFile(install.skillPath, `${install.content}\n`, 'utf8');
for (const file of install.supportingFiles) {
await mkdir(path.dirname(file.targetPath), { recursive: true });
await writeFile(file.targetPath, file.content);
}
}
return pendingInstalls.map((install) => install.skill);
}
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {
return null;
}
}

View File

@@ -510,3 +510,195 @@ test('providerSkillsService lists gemini and cursor skills from their configured
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers managed global skill creation for providers that own a
* writable user skill directory.
*/
test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
const restoreHomeDir = patchHomeDir(tempRoot);
try {
const createdClaudeSkills = await providerSkillsService.addProviderSkills('claude', {
entries: [
{
directoryName: 'claude-global-dir',
content: '---\nname: claude-global\ndescription: Claude global skill\n---\n\nClaude body.\n',
},
],
});
const createdClaudeSkill = createdClaudeSkills[0];
assert.ok(createdClaudeSkill);
assert.equal(createdClaudeSkill.command, '/claude-global');
assert.equal(
createdClaudeSkill.sourcePath.endsWith(path.join('.claude', 'skills', 'claude-global-dir', 'SKILL.md')),
true,
);
assert.match(
await fs.readFile(createdClaudeSkill.sourcePath, 'utf8'),
/Claude body\./,
);
const createdCodexSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
directoryName: 'uploaded-codex-folder',
fileName: 'SKILL.md',
content: '---\nname: codex-global\ndescription: Codex global skill\n---\n\nCodex body.\n',
files: [
{
relativePath: 'scripts/run.js',
content: Buffer.from('console.log("codex skill");\n').toString('base64'),
encoding: 'base64',
},
],
},
],
});
const createdCodexSkill = createdCodexSkills[0];
assert.ok(createdCodexSkill);
assert.equal(createdCodexSkill.command, '$codex-global');
assert.equal(
createdCodexSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'uploaded-codex-folder', 'SKILL.md')),
true,
);
assert.equal(
await fs.readFile(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js'), 'utf8'),
'console.log("codex skill");\n',
);
const fallbackNamedSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
fileName: 'fallback / skill.md',
content: '---\ndescription: Normalized fallback skill\n---\n\nFallback body.\n',
},
],
});
const fallbackNamedSkill = fallbackNamedSkills[0];
assert.ok(fallbackNamedSkill);
assert.equal(fallbackNamedSkill.name, 'fallback-skill');
assert.equal(fallbackNamedSkill.command, '$fallback-skill');
assert.equal(
fallbackNamedSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'fallback-skill', 'SKILL.md')),
true,
);
const replacedCodexSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
directoryName: 'uploaded-codex-folder',
content: '---\nname: replacement\ndescription: Replacement skill\n---\n\nReplacement body.\n',
},
],
});
assert.equal(replacedCodexSkills[0]?.command, '$replacement');
assert.match(await fs.readFile(createdCodexSkill.sourcePath, 'utf8'), /Replacement body\./);
await assert.rejects(
fs.stat(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js')),
{ code: 'ENOENT' },
);
const pendingBatchSkillPath = path.join(tempRoot, '.agents', 'skills', 'pending-batch', 'SKILL.md');
await assert.rejects(
providerSkillsService.addProviderSkills('codex', {
entries: [
{
directoryName: 'pending-batch',
content: '---\nname: pending-batch\n---\n\nPending body.\n',
},
{
directoryName: 'pending-batch',
content: '---\nname: duplicate-batch\n---\n\nDuplicate body.\n',
},
],
}),
/duplicate skill target/i,
);
await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' });
const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
entries: [
{
directoryName: 'gemini-global-dir',
content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
},
],
});
const createdGeminiSkill = createdGeminiSkills[0];
assert.ok(createdGeminiSkill);
assert.equal(createdGeminiSkill.command, '/gemini-global');
assert.equal(
createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
true,
);
const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
entries: [
{
directoryName: 'cursor-global-dir',
content: '---\nname: cursor-global\ndescription: Cursor global skill\n---\n\nCursor body.\n',
},
],
});
const createdCursorSkill = createdCursorSkills[0];
assert.ok(createdCursorSkill);
assert.equal(createdCursorSkill.command, '/cursor-global');
assert.equal(
createdCursorSkill.sourcePath.endsWith(path.join('.cursor', 'skills', 'cursor-global-dir', 'SKILL.md')),
true,
);
const listedClaudeSkills = await providerSkillsService.listProviderSkills('claude');
assert.equal(listedClaudeSkills.some((skill) => skill.name === 'claude-global'), true);
const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true);
const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
await assert.rejects(
providerSkillsService.addProviderSkills('codex', {
entries: [
{
content: '---\nname: unsafe-skill\n---\n',
files: [
{
relativePath: '../outside.js',
content: '',
encoding: 'utf8',
},
],
},
],
}),
/invalid supporting file path/i,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* OpenCode reuses other providers' skill folders, so it should not accept
* direct skill writes through the managed provider endpoint.
*/
test('providerSkillsService rejects managed skill creation for opencode', { concurrency: false }, async () => {
await assert.rejects(
providerSkillsService.addProviderSkills('opencode', {
entries: [
{
directoryName: 'opencode-global-dir',
content: '---\nname: opencode-global\ndescription: Unsupported skill\n---\n\nOpenCode body.\n',
},
],
}),
/does not support managed global skills/i,
);
});

View File

@@ -1,8 +1,9 @@
import type { Server as HttpServer } from 'node:http';
import { WebSocketServer, type VerifyClientCallbackSync } from 'ws';
import { WebSocket, WebSocketServer, type VerifyClientCallbackSync } from 'ws';
import { handleChatConnection } from '@/modules/websocket/services/chat-websocket.service.js';
import { VIEWER_COOKIE_NAME } from '@/modules/browser-use/index.js';
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
@@ -13,8 +14,21 @@ type WebSocketServerDependencies = {
chat: Parameters<typeof handleChatConnection>[2];
shell: Parameters<typeof handleShellConnection>[1];
getPluginPort: Parameters<typeof handlePluginWsProxy>[2];
browserUseViewer?: (ws: WebSocket, pathname: string) => void;
authenticateBrowserUseViewer?: (pathname: string, token: string | null) => boolean;
};
function readCookieValue(header: unknown, name: string): string | null {
if (!header) return null;
const prefix = `${name}=`;
const cookie = String(header).split(';').map((part) => part.trim()).find((part) => part.startsWith(prefix));
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;
}
function getBrowserUseViewerToken(url: URL, headers: Record<string, unknown>): string | null {
return url.searchParams.get('viewerToken') || readCookieValue(headers.cookie, VIEWER_COOKIE_NAME);
}
/**
* Creates and wires the server-wide websocket gateway used for chat, shell, and
* plugin proxy routes.
@@ -27,7 +41,17 @@ export function createWebSocketServer(
server,
verifyClient: ((
info: Parameters<VerifyClientCallbackSync<AuthenticatedWebSocketRequest>>[0]
) => verifyWebSocketClient(info, dependencies.verifyClient)),
) => {
const requestUrl = new URL(info.req.url ?? '/', 'http://localhost');
if (
requestUrl.pathname.startsWith('/api/browser-use/sessions/')
&& requestUrl.pathname.endsWith('/viewer/websockify')
) {
const token = getBrowserUseViewerToken(requestUrl, info.req.headers as Record<string, unknown>);
return Boolean(dependencies.authenticateBrowserUseViewer?.(requestUrl.pathname, token));
}
return verifyWebSocketClient(info, dependencies.verifyClient);
}),
});
wss.on('connection', (ws, request) => {
@@ -68,6 +92,11 @@ export function createWebSocketServer(
return;
}
if (pathname.startsWith('/api/browser-use/sessions/') && pathname.endsWith('/viewer/websockify')) {
dependencies.browserUseViewer?.(ws, pathname);
return;
}
console.log('[WARN] Unknown WebSocket path:', pathname);
ws.close();
});

View File

@@ -12,6 +12,7 @@ import type {
ProviderModelsDefinition,
ProviderMcpServer,
ProviderSessionActiveModelChange,
ProviderSkillCreateInput,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
@@ -101,6 +102,15 @@ export interface IProviderSkills {
* Lists all skills visible to this provider for the optional workspace.
*/
listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]>;
/**
* Writes one or more global user-scoped skills for this provider.
*
* Implementations should install the supplied markdown entries into the
* provider's writable user skill folder and return the normalized skill
* records that were written.
*/
addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]>;
}
// ---------------------------

View File

@@ -320,6 +320,47 @@ export type ProviderSkillListOptions = {
workspacePath?: string;
};
/**
* One supporting file bundled with an uploaded provider skill.
*
* `relativePath` is resolved below the installed skill directory and must never
* be absolute or contain traversal segments. Text files may use `utf8`; binary
* scripts and assets should use `base64` so JSON transport does not corrupt
* their bytes.
*/
export type ProviderSkillCreateFile = {
relativePath: string;
content: string;
encoding: 'utf8' | 'base64';
};
/**
* One skill markdown payload submitted for provider-managed installation.
*
* `content` is the raw markdown body that will be written to `SKILL.md`.
* `directoryName` lets callers control the target folder name explicitly when
* they want stable filesystem paths that differ from the markdown front matter
* `name` field. `fileName` is optional upload metadata used only as a final
* fallback when no directory name or front matter name is present. `files`
* carries scripts, references, and other files from a complete skill folder.
*/
export type ProviderSkillCreateEntry = {
content: string;
directoryName?: string;
fileName?: string;
files?: ProviderSkillCreateFile[];
};
/**
* Shared input accepted by provider skill creation operations.
*
* The service layer batches multiple skill definitions in one request. Each
* entry can contain only markdown or a complete skill folder.
*/
export type ProviderSkillCreateInput = {
entries: ProviderSkillCreateEntry[];
};
/**
* Normalized skill record returned by provider skill adapters.
*

View File

@@ -957,9 +957,25 @@ export async function readProviderSkillMarkdownDefinition(
skillPath: string,
): Promise<{ name: string; description: string }> {
const content = await readFile(skillPath, 'utf8');
return readProviderSkillMarkdownDefinitionFromContent(
content,
path.basename(path.dirname(skillPath)),
);
}
/**
* Reads the `name` and `description` fields from raw skill markdown content.
*
* This keeps filesystem discovery and newly uploaded skill creation aligned on
* the same front matter parsing rules. `fallbackName` is used when the markdown
* omits a `name` field so callers still get a stable, non-empty skill id.
*/
export function readProviderSkillMarkdownDefinitionFromContent(
content: string,
fallbackName: string,
): { name: string; description: string } {
const parsed = parseFrontMatter(content);
const data = readObjectRecord(parsed.data) ?? {};
const fallbackName = path.basename(path.dirname(skillPath));
return {
name: readOptionalString(data.name) ?? fallbackName,

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
Clock3,
@@ -7,6 +7,7 @@ import {
ExternalLink,
Loader2,
MonitorPlay,
MousePointer2,
RefreshCw,
Settings,
Square,
@@ -19,9 +20,14 @@ import { Badge, Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api';
import type { SettingsMainTab } from '../../settings/types/types';
const BROWSER_USE_GUIDE_URL = 'https://cloudcli.ai/docs/browser-use';
const BROWSER_USE_CACHE_TTL_MS = 30_000;
type BrowserUseStatus = {
enabled: boolean;
available: boolean;
backend: 'playwright' | 'camoufox-vnc';
browserBackend: 'playwright' | 'camoufox-vnc';
playwrightInstalled: boolean;
chromiumInstalled: boolean;
installInProgress: boolean;
@@ -39,6 +45,9 @@ type BrowserUseSession = {
updatedAt: string;
lastAction: string | null;
message: string | null;
backend?: 'playwright' | 'camoufox-vnc';
viewerUrl?: string | null;
viewerEmbedUrl?: string | null;
createdBy: 'agent';
profileName: string | null;
viewport: {
@@ -54,17 +63,48 @@ type BrowserUseSession = {
type BrowserUsePanelProps = {
isVisible: boolean;
projectId?: string | null;
onShowSettings?: (tab?: SettingsMainTab) => void;
};
type BrowserUsePanelCacheEntry = {
status: BrowserUseStatus | null;
sessions: BrowserUseSession[];
selectedSessionId: string | null;
updatedAt: number;
};
const browserUsePanelCache = new Map<string, BrowserUsePanelCacheEntry>();
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
const text = await response.text();
let data: any = {};
if (text) {
try {
data = JSON.parse(text);
} catch {
throw new Error(response.ok ? 'Received an invalid Browser response.' : `Browser request failed (${response.status}).`);
}
}
if (!response.ok || data.success === false) {
throw new Error(data.error || data.details || `Request failed (${response.status})`);
}
return data as T;
}
async function fetchBrowserPanelData() {
const [statusResponse, sessionsResponse] = await Promise.all([
authenticatedFetch('/api/browser-use/status'),
authenticatedFetch('/api/browser-use/sessions'),
]);
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
return {
status: statusData.data,
sessions: [...sessionsData.data.sessions].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)),
};
}
function formatRelativeTime(value: string | null): string {
if (!value) return 'Never';
@@ -119,20 +159,42 @@ function getStatusDot(status: BrowserUseSession['status']): string {
return 'bg-border';
}
function getEngineLabel(backend?: BrowserUseStatus['backend'] | BrowserUseSession['backend']): string {
return backend === 'camoufox-vnc' ? 'Visible browser' : 'Playwright';
}
const PROMPTS = [
'Use Browser to inspect the checkout flow and report any broken UI states.',
'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
];
export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUsePanelProps) {
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [sessions, setSessions] = useState<BrowserUseSession[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
function getBrowserUseCacheKey(projectId?: string | null): string {
return projectId ? `browser-use:project:${projectId}` : 'browser-use:global';
}
function getFreshCacheEntry(cacheKey: string): BrowserUsePanelCacheEntry | null {
const entry = browserUsePanelCache.get(cacheKey);
if (!entry || Date.now() - entry.updatedAt > BROWSER_USE_CACHE_TTL_MS) {
return null;
}
return entry;
}
export default function BrowserUsePanel({ isVisible, projectId, onShowSettings }: BrowserUsePanelProps) {
const cacheKey = getBrowserUseCacheKey(projectId);
const initialCacheEntry = getFreshCacheEntry(cacheKey);
const [status, setStatus] = useState<BrowserUseStatus | null>(() => initialCacheEntry?.status ?? null);
const [sessions, setSessions] = useState<BrowserUseSession[]>(() => initialCacheEntry?.sessions ?? []);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(() => (
initialCacheEntry?.selectedSessionId || initialCacheEntry?.sessions[0]?.id || null
));
const [hasLoadedOnce, setHasLoadedOnce] = useState(Boolean(initialCacheEntry));
const [isRefreshing, setIsRefreshing] = useState(false);
const [isBusy, setIsBusy] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeLoadIdRef = useRef(0);
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
@@ -140,8 +202,12 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
);
const activeSessions = sessions.filter((session) => session.status === 'ready');
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
const runtimeLabel = !status?.enabled
const isInitialLoading = isRefreshing && !hasLoadedOnce && sessions.length === 0;
const isBackgroundRefreshing = isRefreshing && !isInitialLoading;
const needsBrowserBinaries = Boolean(status?.enabled && !status.available);
const runtimeLabel = isInitialLoading
? 'Loading'
: !status?.enabled
? 'Disabled'
: status.available
? 'Ready'
@@ -157,29 +223,72 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
: null;
const refresh = useCallback(async () => {
const loadId = activeLoadIdRef.current + 1;
activeLoadIdRef.current = loadId;
setIsRefreshing(true);
try {
const [statusResponse, sessionsResponse] = await Promise.all([
authenticatedFetch('/api/browser-use/status'),
authenticatedFetch('/api/browser-use/sessions'),
]);
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
const nextSessions = sessionsData.data.sessions;
setStatus(statusData.data);
let nextData: Awaited<ReturnType<typeof fetchBrowserPanelData>>;
try {
nextData = await fetchBrowserPanelData();
} catch (error) {
if (loadId !== activeLoadIdRef.current) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 350));
nextData = await fetchBrowserPanelData();
}
if (activeLoadIdRef.current !== loadId) {
return;
}
const nextSessions = nextData.sessions;
setStatus(nextData.status);
setSessions(nextSessions);
setSelectedSessionId((current) => (
current && nextSessions.some((session) => session.id === current)
setHasLoadedOnce(true);
let nextSelectedSessionId: string | null = null;
setSelectedSessionId((current) => {
nextSelectedSessionId = current && nextSessions.some((session) => session.id === current)
? current
: nextSessions[0]?.id || null
));
: nextSessions[0]?.id || null;
return nextSelectedSessionId;
});
browserUsePanelCache.set(cacheKey, {
status: nextData.status,
sessions: nextSessions,
selectedSessionId: nextSelectedSessionId,
updatedAt: Date.now(),
});
setError(null);
} catch (err) {
if (activeLoadIdRef.current !== loadId) {
return;
}
setHasLoadedOnce(true);
setError(err instanceof Error ? err.message : 'Failed to load Browser');
} finally {
setIsRefreshing(false);
if (activeLoadIdRef.current === loadId) {
setIsRefreshing(false);
}
}
}, []);
}, [cacheKey]);
useEffect(() => {
const cachedEntry = browserUsePanelCache.get(cacheKey);
if (!cachedEntry) return;
browserUsePanelCache.set(cacheKey, {
...cachedEntry,
selectedSessionId,
});
}, [cacheKey, selectedSessionId]);
useEffect(() => {
const cachedEntry = getFreshCacheEntry(cacheKey);
setStatus(cachedEntry?.status ?? null);
setSessions(cachedEntry?.sessions ?? []);
setSelectedSessionId(cachedEntry?.selectedSessionId || cachedEntry?.sessions[0]?.id || null);
setHasLoadedOnce(Boolean(cachedEntry));
setError(null);
activeLoadIdRef.current += 1;
}, [cacheKey]);
useEffect(() => {
if (!isVisible) return;
@@ -253,6 +362,10 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<span>{formatRelativeTime(session.updatedAt)}</span>
<span className="truncate">- {formatAction(session.lastAction)}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 pl-3.5 text-[10px] text-muted-foreground">
<span className="rounded border border-border/70 bg-background/70 px-1.5 py-0.5">{getEngineLabel(session.backend)}</span>
<span className="rounded border border-border/70 bg-background/70 px-1.5 py-0.5">{session.profileName || 'Temporary'}</span>
</div>
</button>
);
};
@@ -270,9 +383,18 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
</div>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
{status?.enabled
? 'Agent browser sessions appear here while an AI task is using Browser.'
: 'Enable Browser in settings to let agents open monitored browser sessions.'}
? 'When an agent opens a browser, you can watch the latest screenshot, take control in a new tab, or end the running session.'
: 'Enable Browser to let agents open websites, test flows, capture screenshots, and debug UI from a real page.'}
</p>
<a
href={BROWSER_USE_GUIDE_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Read the Browser guide
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
@@ -312,10 +434,19 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
</div>
);
const renderLoadingState = () => (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="flex items-center gap-3 rounded-md border border-border bg-card/40 px-4 py-3 text-sm text-muted-foreground shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
Loading browser sessions...
</div>
</div>
);
const renderBrowserSurface = (fullscreen = false) => (
<div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
{selectedSession?.screenshotDataUrl ? (
<div className="relative inline-block max-h-full">
<div className="group relative inline-block max-h-full">
<img
src={selectedSession.screenshotDataUrl}
alt="Browser session screenshot"
@@ -329,6 +460,18 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
</div>
)}
{selectedSession?.viewerEmbedUrl && selectedSession.status === 'ready' && (
<button
type="button"
onClick={() => window.open(selectedSession.viewerUrl || selectedSession.viewerEmbedUrl || '', '_blank', 'noopener,noreferrer')}
className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition focus-visible:bg-black/30 focus-visible:opacity-100 focus-visible:outline-none group-hover:bg-black/30 group-hover:opacity-100"
>
<span className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-black/80 px-3 py-2 text-sm font-medium text-white shadow-lg">
<MousePointer2 className="h-4 w-4" />
Take control
</span>
</button>
)}
</div>
) : (
<div className="px-6 text-center">
@@ -350,10 +493,29 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
{runtimeLabel}
</Badge>
<Badge variant="outline" className="border-border bg-background text-[10px] text-muted-foreground">
{getEngineLabel(status?.backend)}
</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">Monitor browser sessions opened by AI agents.</p>
<p className="mt-0.5 text-xs text-muted-foreground">Watch and manage browser sessions agents use to test real websites.</p>
{isBackgroundRefreshing && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<RefreshCw className="h-3 w-3 animate-spin" />
Refreshing sessions...
</div>
)}
</div>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => window.open(BROWSER_USE_GUIDE_URL, '_blank', 'noopener,noreferrer')}
title="Open Browser guide"
aria-label="Open Browser guide"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
{onShowSettings && (
<Button
variant="ghost"
@@ -425,7 +587,7 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
</div>
{sessions.length === 0 ? (
renderEmptyState()
isInitialLoading ? renderLoadingState() : renderEmptyState()
) : (
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
@@ -441,14 +603,32 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
</div>
<div className="mt-1 flex flex-wrap gap-1.5 text-[10px] text-muted-foreground">
<span className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5">{getEngineLabel(selectedSession?.backend || status?.backend)}</span>
<span className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5">Profile: {selectedSession?.profileName || 'Temporary'}</span>
<span className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5">Updated {formatRelativeTime(selectedSession?.updatedAt || null)}</span>
</div>
</div>
<div className="hidden text-xs text-muted-foreground md:block">
{formatAction(selectedSession?.lastAction || null)}
</div>
{selectedSession?.viewerUrl && selectedSession.status === 'ready' && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => window.open(selectedSession.viewerUrl || '', '_blank', 'noopener,noreferrer')}
title="Open live browser control in a new tab"
aria-label="Open live browser control in a new tab"
>
<MousePointer2 className="h-4 w-4" />
Take control
</Button>
)}
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
<Expand className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="Stop session" aria-label="Stop session">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="End session" aria-label="End session">
<Square className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
@@ -475,6 +655,11 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{sessions.length > 0 ? (
<div className="space-y-2">{sessions.map(renderSessionItem)}</div>
) : isInitialLoading ? (
<div className="flex items-center justify-center gap-2 rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading sessions...
</div>
) : (
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
No agent browser sessions.
@@ -505,7 +690,7 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<div className="mt-3 grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Square className="h-4 w-4" />
Stop
End
</Button>
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
<Trash2 className="h-4 w-4" />

View File

@@ -11,7 +11,7 @@ export function decodeHtmlEntities(text: string) {
export function normalizeInlineCodeFences(text: string) {
if (!text || typeof text !== 'string') return text;
try {
return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
return text.replace(/```[ \t]*([^\n\r]+?)[ \t]*```/g, '`$1`');
} catch {
return text;
}

View File

@@ -200,7 +200,11 @@ function MainContent({
{shouldShowBrowserTab && activeTab === 'browser' && (
<div className="h-full overflow-hidden">
<BrowserUsePanel isVisible={activeTab === 'browser'} onShowSettings={onShowSettings} />
<BrowserUsePanel
isVisible={activeTab === 'browser'}
projectId={selectedProject.projectId}
onShowSettings={onShowSettings}
/>
</div>
)}

View File

@@ -73,7 +73,15 @@ export default function MainContentTitle({
<h2 className="scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground">
{getSessionTitle(selectedSession)}
</h2>
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
<div className="flex min-w-0 items-center gap-2 text-[11px] leading-tight text-muted-foreground">
<span className="min-w-0 truncate">{selectedProject.displayName}</span>
<span
className="hidden min-w-0 max-w-[45%] flex-shrink truncate border-l border-border/60 pl-2 font-mono text-[10px] sm:block"
title={selectedSession.id}
>
{selectedSession.id}
</span>
</div>
</div>
) : showChatNewSession ? (
<div className="min-w-0">

View File

@@ -5,7 +5,7 @@ import type { ProviderAuthStatus } from '../../provider-auth/types';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = LLMProvider;
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
export type ProjectSortOrder = 'name' | 'date';
export type SaveStatus = 'success' | 'error' | null;
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';

View File

@@ -1,5 +1,6 @@
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
import { Button } from '../../../shared/view/ui';
import SettingsSidebar from '../view/SettingsSidebar';
@@ -101,12 +102,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
</div>
{/* Body: sidebar + content */}
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className="flex min-h-0 min-w-0 flex-1 flex-col md:flex-row">
<SettingsSidebar activeTab={activeTab} onChange={setActiveTab} />
{/* Content */}
<main className="flex-1 overflow-y-auto">
<div key={activeTab} className="settings-content-enter space-y-6 p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
<main className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<div key={activeTab} className="settings-content-enter min-w-0 space-y-6 overflow-x-hidden p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
{activeTab === 'appearance' && (
<AppearanceSettingsTab
projectSortOrder={projectSortOrder}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { AgentCategory, AgentProvider } from '../../../types/types';
@@ -22,6 +22,11 @@ export default function AgentsSettingsTab({
}: AgentsSettingsTabProps) {
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
const visibleCategories = useMemo<AgentCategory[]>(() => (
selectedAgent === 'opencode'
? ['account', 'permissions', 'mcp']
: ['account', 'permissions', 'mcp', 'skills']
), [selectedAgent]);
const visibleAgents = useMemo<AgentProvider[]>(() => {
return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
@@ -57,8 +62,14 @@ export default function AgentsSettingsTab({
providerAuthStatus.opencode,
]);
useEffect(() => {
if (!visibleCategories.includes(selectedCategory)) {
setSelectedCategory(visibleCategories[0] ?? 'account');
}
}, [selectedCategory, visibleCategories]);
return (
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] min-w-0 flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<AgentSelectorSection
agents={visibleAgents}
selectedAgent={selectedAgent}
@@ -66,8 +77,10 @@ export default function AgentsSettingsTab({
agentContextById={agentContextById}
/>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<AgentCategoryTabsSection
categories={visibleCategories}
selectedAgent={selectedAgent}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>

View File

@@ -1,6 +1,8 @@
import type { AgentCategoryContentSectionProps } from '../types';
import type { McpProject } from '../../../../../mcp/types';
import { McpServers } from '../../../../../mcp';
import type { SkillsProject } from '../../../../../skills/types';
import { ProviderSkills } from '../../../../../skills';
import AccountContent from './content/AccountContent';
import PermissionsContent from './content/PermissionsContent';
@@ -18,7 +20,7 @@ export default function AgentCategoryContentSection({
projects,
}: AgentCategoryContentSectionProps) {
return (
<div className="flex-1 overflow-y-auto p-3 md:p-4">
<div className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden p-3 md:p-4">
{selectedCategory === 'account' && (
<AccountContent
agent={selectedAgent}
@@ -84,6 +86,18 @@ export default function AgentCategoryContentSection({
}))}
/>
)}
{selectedCategory === 'skills' && selectedAgent !== 'opencode' && (
<ProviderSkills
selectedProvider={selectedAgent}
currentProjects={projects.map<SkillsProject>((project) => ({
projectId: project.name,
displayName: project.displayName,
fullPath: project.fullPath,
path: project.path,
}))}
/>
)}
</div>
);
}

View File

@@ -1,11 +1,11 @@
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../../../lib/utils';
import type { AgentCategory } from '../../../../types/types';
import type { AgentCategoryTabsSectionProps } from '../types';
const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export default function AgentCategoryTabsSection({
categories,
selectedAgent,
selectedCategory,
onSelectCategory,
}: AgentCategoryTabsSectionProps) {
@@ -14,7 +14,7 @@ export default function AgentCategoryTabsSection({
return (
<div className="flex-shrink-0 border-b border-border">
<div role="tablist" className="flex overflow-x-auto px-2 md:px-4">
{AGENT_CATEGORIES.map((category) => (
{categories.map((category) => (
<button
key={category}
role="tab"
@@ -30,6 +30,9 @@ export default function AgentCategoryTabsSection({
{category === 'account' && t('tabs.account')}
{category === 'permissions' && t('tabs.permissions')}
{category === 'mcp' && t('tabs.mcpServers')}
{category === 'skills' && t('tabs.skills', {
defaultValue: selectedAgent === 'opencode' ? 'Shared Skills' : 'Skills',
})}
</button>
))}
</div>

View File

@@ -32,6 +32,8 @@ export type AgentsSettingsTabProps = {
};
export type AgentCategoryTabsSectionProps = {
categories: AgentCategory[];
selectedAgent: AgentProvider;
selectedCategory: AgentCategory;
onSelectCategory: (category: AgentCategory) => void;
};

View File

@@ -1,22 +1,32 @@
import { useCallback, useEffect, useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { Download, ExternalLink, Eye, Loader2, Zap } from 'lucide-react';
import { Button } from '../../../../../shared/view/ui';
import { Button, Input } from '../../../../../shared/view/ui';
import { authenticatedFetch } from '../../../../../utils/api';
import SettingsCard from '../../SettingsCard';
import SettingsRow from '../../SettingsRow';
import SettingsSection from '../../SettingsSection';
import SettingsToggle from '../../SettingsToggle';
const BROWSER_USE_GUIDE_URL = 'https://cloudcli.ai/docs/browser-use';
type BrowserUseSettings = {
enabled: boolean;
persistSessions: boolean;
defaultProfileName: string;
browserBackend: 'playwright' | 'camoufox-vnc';
};
type BrowserUseStatus = {
enabled: boolean;
available: boolean;
backend: 'playwright' | 'camoufox-vnc';
browserBackend: 'playwright' | 'camoufox-vnc';
playwrightInstalled: boolean;
chromiumInstalled: boolean;
camoufoxInstalled: boolean;
noVncInstalled: boolean;
x11vncInstalled: boolean;
installInProgress: boolean;
message: string;
};
@@ -32,16 +42,20 @@ async function readJson<T>(response: Response): Promise<T> {
export default function BrowserUseSettingsTab() {
const [settings, setSettings] = useState<BrowserUseSettings | null>(null);
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [hasLoadedSettings, setHasLoadedSettings] = useState(false);
const [isSettingsLoading, setIsSettingsLoading] = useState(true);
const [isStatusLoading, setIsStatusLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [profileNameDraft, setProfileNameDraft] = useState('default');
const loadSettings = useCallback(async () => {
const settingsResponse = await authenticatedFetch('/api/browser-use/settings');
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
setSettings(settingsData.data.settings);
setHasLoadedSettings(true);
setProfileNameDraft(settingsData.data.settings.defaultProfileName || 'default');
}, []);
const loadStatus = useCallback(async () => {
@@ -52,6 +66,7 @@ export default function BrowserUseSettingsTab() {
useEffect(() => {
setError(null);
setHasLoadedSettings(false);
setIsSettingsLoading(true);
setIsStatusLoading(true);
@@ -74,6 +89,7 @@ export default function BrowserUseSettingsTab() {
});
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
setSettings(data.data.settings);
setHasLoadedSettings(true);
window.dispatchEvent(new Event('browserUseSettingsChanged'));
setIsStatusLoading(true);
await loadStatus();
@@ -101,8 +117,21 @@ export default function BrowserUseSettingsTab() {
}
};
const saveProfileName = async () => {
const nextName = profileNameDraft.trim() || 'default';
setProfileNameDraft(nextName);
if (nextName === settings?.defaultProfileName) {
return;
}
await updateSettings({ defaultProfileName: nextName });
};
const browserEnabled = settings?.enabled === true;
const needsBrowserBinaries = Boolean(browserEnabled && status && (!status.playwrightInstalled || !status.chromiumInstalled));
const browserDisabled = hasLoadedSettings && settings?.enabled === false;
const persistSessions = settings?.persistSessions === true;
const selectedBackend = settings?.browserBackend || 'playwright';
const effectiveBackend = status?.backend || 'playwright';
const needsBrowserBinaries = Boolean(browserEnabled && status && !status.available);
const runtimeLabel = (installed?: boolean) => {
if (isStatusLoading && !status) {
return 'checking...';
@@ -114,33 +143,165 @@ export default function BrowserUseSettingsTab() {
<div className="space-y-8">
<SettingsSection
title="Browser"
description="Allow agents to create guarded Playwright browser sessions that you can monitor from the Browser tab."
description="Give coding agents a working browser so they can open websites, test flows, capture screenshots, and help debug what users actually see."
>
<SettingsCard divided>
<SettingsRow
label="Enable Browser"
description="Registers Browser for supported agents. Agents can create browser sessions; you can watch, stop, and delete them."
label="Give Agents Browser Access"
description="Let agents use a browser during coding tasks while you can watch live sessions, open them in a tab, and stop them at any time."
>
{isSettingsLoading && !settings ? (
{isSettingsLoading && !hasLoadedSettings ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
) : hasLoadedSettings ? (
<SettingsToggle
checked={browserEnabled}
onChange={(value) => void updateSettings({ enabled: value })}
ariaLabel="Enable Browser"
ariaLabel="Give Agents Browser Access"
disabled={isSaving}
/>
) : (
<span className="text-sm text-muted-foreground">Unavailable</span>
)}
</SettingsRow>
{browserDisabled && (
<div className="px-4 py-4">
<a
href={BROWSER_USE_GUIDE_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Read the Browser guide
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{error && (
<div className="px-4 py-4">
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
</div>
)}
{browserEnabled && (
<>
<div className="space-y-3 px-4 py-4">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Browser Engine</div>
<div className="mt-0.5 text-sm text-muted-foreground">
Pick the kind of browser experience agents should use for new sessions.
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{([
{
value: 'playwright' as const,
label: 'Playwright',
description: 'Best for quick checks, screenshots, and automated page interaction when no manual login is needed.',
icon: Zap,
},
{
value: 'camoufox-vnc' as const,
label: 'Camoufox + noVNC',
description: 'Best when a person may need to log in, approve a step, or watch the browser session live.',
icon: Eye,
},
]).map((option) => {
const Icon = option.icon;
const selected = selectedBackend === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => void updateSettings({ browserBackend: option.value })}
disabled={isSaving || isSettingsLoading}
className={[
'group flex min-h-[88px] items-start gap-3 rounded-lg border px-3 py-3 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
selected
? 'border-primary bg-primary/5 text-foreground shadow-sm'
: 'border-border bg-background hover:border-foreground/20 hover:bg-muted/40',
(isSaving || isSettingsLoading) ? 'cursor-not-allowed opacity-60' : '',
].join(' ')}
aria-pressed={selected}
>
<span className={[
'mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border',
selected ? 'border-primary/30 bg-primary/10 text-primary' : 'border-border bg-muted/40 text-muted-foreground',
].join(' ')}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{option.label}</span>
<span className="mt-1 block text-xs leading-relaxed text-muted-foreground">{option.description}</span>
</span>
</button>
);
})}
</div>
</div>
<SettingsRow
label="Remember Browser Logins"
description="Keep cookies and site storage in a named profile so agents can reuse signed-in sessions instead of starting from scratch."
>
{isSettingsLoading && !settings ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<SettingsToggle
checked={persistSessions}
onChange={(value) => void updateSettings({ persistSessions: value })}
ariaLabel="Remember Browser Logins"
disabled={isSaving}
/>
)}
</SettingsRow>
{persistSessions && (
<SettingsRow
label="Default Browser Profile"
description="New browser sessions use this profile by default, so saved logins stay tied to a predictable workspace."
>
<Input
value={profileNameDraft}
onChange={(event) => setProfileNameDraft(event.target.value)}
onBlur={() => void saveProfileName()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
disabled={isSaving || isSettingsLoading}
className="w-40"
aria-label="Default Browser Profile"
/>
</SettingsRow>
)}
</>
)}
{browserEnabled && (
<div className="space-y-4 px-4 py-4">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">
Backend: {effectiveBackend === 'camoufox-vnc' ? 'Camoufox + noVNC' : 'Playwright'}
</span>
<span className="rounded-md border border-border px-2 py-1">
Playwright: {runtimeLabel(status?.playwrightInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1">
Chromium: {runtimeLabel(status?.chromiumInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1">
Camoufox: {runtimeLabel(status?.camoufoxInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1">
noVNC: {runtimeLabel(status?.noVncInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1">
Status: {isStatusLoading && !status ? 'checking...' : status?.available ? 'ready' : browserEnabled ? 'setup required' : 'disabled'}
</span>
@@ -172,12 +333,17 @@ export default function BrowserUseSettingsTab() {
</div>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
)}
<a
href={BROWSER_USE_GUIDE_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Read the Browser guide
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
</SettingsCard>
</SettingsSection>
</div>

View File

@@ -43,7 +43,7 @@ function Sidebar({
}: SidebarProps) {
const { t } = useTranslation(['sidebar', 'common']);
const { isPWA } = useDeviceSettings({ trackMobile: false });
const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
const { updateAvailable, restartRequired, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
'siteboon',
'claudecodeui',
);
@@ -224,6 +224,7 @@ function Sidebar({
onExpand={handleExpandSidebar}
onShowSettings={onShowSettings}
updateAvailable={updateAvailable}
restartRequired={restartRequired}
onShowVersionModal={() => setShowVersionModal(true)}
t={t}
/>
@@ -296,6 +297,7 @@ function Sidebar({
onCreateProject={() => setShowNewProject(true)}
onCollapseSidebar={handleCollapseSidebar}
updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, Sparkles, PanelLeftOpen, Bug } from 'lucide-react';
import { Settings, Sparkles, PanelLeftOpen, Bug, AlertTriangle } from 'lucide-react';
import type { TFunction } from 'i18next';
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
@@ -16,6 +16,7 @@ type SidebarCollapsedProps = {
onExpand: () => void;
onShowSettings: () => void;
updateAvailable: boolean;
restartRequired: boolean;
onShowVersionModal: () => void;
t: TFunction;
};
@@ -24,6 +25,7 @@ export default function SidebarCollapsed({
onExpand,
onShowSettings,
updateAvailable,
restartRequired,
onShowVersionModal,
t,
}: SidebarCollapsedProps) {
@@ -75,6 +77,18 @@ export default function SidebarCollapsed({
<DiscordIcon className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
{/* Restart-required indicator */}
{restartRequired && (
<div
className="relative flex h-8 w-8 items-center justify-center rounded-lg"
aria-label={t('version.restartRequired')}
title={t('version.restartRequired')}
>
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span className="absolute right-1.5 top-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" />
</div>
)}
{/* Update indicator */}
{updateAvailable && (
<button

View File

@@ -141,6 +141,7 @@ type SidebarContentProps = {
onCreateProject: () => void;
onCollapseSidebar: () => void;
updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
@@ -178,6 +179,7 @@ export default function SidebarContent({
onCreateProject,
onCollapseSidebar,
updateAvailable,
restartRequired,
releaseInfo,
latestVersion,
currentVersion,
@@ -553,6 +555,7 @@ export default function SidebarContent({
<SidebarFooter
updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, ArrowUpCircle, Bug } from 'lucide-react';
import { Settings, ArrowUpCircle, Bug, AlertTriangle } from 'lucide-react';
import type { TFunction } from 'i18next';
import { IS_PLATFORM } from '../../../../constants/config';
import type { ReleaseInfo } from '../../../../types/sharedTypes';
@@ -18,6 +18,7 @@ function DiscordIcon({ className }: { className?: string }) {
type SidebarFooterProps = {
updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
@@ -28,6 +29,7 @@ type SidebarFooterProps = {
export default function SidebarFooter({
updateAvailable,
restartRequired,
releaseInfo,
latestVersion,
currentVersion,
@@ -37,6 +39,22 @@ export default function SidebarFooter({
}: SidebarFooterProps) {
return (
<div className="flex-shrink-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0)' }}>
{/* Restart-required banner: the running server version differs from the
installed/frontend version (updated but not restarted). */}
{restartRequired && (
<>
<div className="nav-divider" />
<div className="px-2 py-1.5 md:px-2 md:py-1.5">
<div className="flex items-center gap-2.5 rounded-lg border border-amber-300/60 bg-amber-50/80 px-2.5 py-2 dark:border-amber-700/40 dark:bg-amber-900/15">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-500 dark:text-amber-400" />
<span className="min-w-0 flex-1 text-xs font-medium text-amber-700 dark:text-amber-300">
{t('version.restartRequired')}
</span>
</div>
</div>
</>
)}
{/* Update banner */}
{updateAvailable && (
<>

View File

@@ -0,0 +1,348 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type {
ApiResponse,
ProviderSkill,
ProviderSkillCreatePayload,
ProviderSkillsResponse,
SkillsProject,
SkillsProvider,
SkillsScope,
} from '../types';
type SkillsCacheEntry = {
skills: ProviderSkill[];
updatedAt: number;
};
type ProjectTarget = {
projectId: string;
displayName: string;
path: string;
};
const SKILLS_CACHE_TTL_MS = 5 * 60_000;
const skillsCache = new Map<string, SkillsCacheEntry>();
const SKILL_SCOPE_ORDER: Record<SkillsScope, number> = {
user: 0,
plugin: 1,
repo: 2,
project: 3,
admin: 4,
system: 5,
};
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const getApiErrorMessage = (payload: unknown, fallback: string): string => {
if (!payload || typeof payload !== 'object') {
return fallback;
}
const record = payload as Record<string, unknown>;
const error = record.error;
if (error && typeof error === 'object') {
const message = (error as Record<string, unknown>).message;
if (typeof message === 'string' && message.trim()) {
return message;
}
}
if (typeof error === 'string' && error.trim()) {
return error;
}
const details = record.details;
if (typeof details === 'string' && details.trim()) {
return details;
}
return fallback;
};
const isSkillsScope = (value: unknown): value is SkillsScope => (
value === 'user'
|| value === 'project'
|| value === 'plugin'
|| value === 'repo'
|| value === 'admin'
|| value === 'system'
);
const normalizeScope = (value: unknown): SkillsScope => (
isSkillsScope(value) ? value : 'user'
);
const createProjectTargets = (projects: SkillsProject[]): ProjectTarget[] => {
const seenPaths = new Set<string>();
return projects.reduce<ProjectTarget[]>((acc, project) => {
const projectPath = project.fullPath || project.path || '';
if (!projectPath || seenPaths.has(projectPath)) {
return acc;
}
seenPaths.add(projectPath);
acc.push({
projectId: project.projectId,
displayName: project.displayName || project.projectId,
path: projectPath,
});
return acc;
}, []);
};
const normalizeSkill = (
provider: SkillsProvider,
skill: Partial<ProviderSkill>,
project?: ProjectTarget,
): ProviderSkill => {
const scope = normalizeScope(skill.scope);
const shouldAttachProject = scope === 'project' || scope === 'repo';
return {
provider,
name: String(skill.name ?? ''),
description: String(skill.description ?? ''),
command: String(skill.command ?? ''),
scope,
sourcePath: String(skill.sourcePath ?? ''),
pluginName: typeof skill.pluginName === 'string' ? skill.pluginName : undefined,
pluginId: typeof skill.pluginId === 'string' ? skill.pluginId : undefined,
projectDisplayName: shouldAttachProject
? project?.displayName ?? skill.projectDisplayName
: skill.projectDisplayName,
projectPath: shouldAttachProject
? project?.path ?? skill.projectPath
: skill.projectPath,
};
};
const getSkillIdentity = (skill: ProviderSkill): string => (
[
skill.provider,
skill.scope,
skill.command,
skill.sourcePath || 'no-source-path',
skill.projectPath || 'global',
].join(':')
);
const sortSkills = (skills: ProviderSkill[]): ProviderSkill[] => (
[...skills].sort((left, right) => {
const scopeDelta = SKILL_SCOPE_ORDER[left.scope] - SKILL_SCOPE_ORDER[right.scope];
if (scopeDelta !== 0) {
return scopeDelta;
}
const projectDelta = (left.projectDisplayName || '').localeCompare(right.projectDisplayName || '');
if (projectDelta !== 0) {
return projectDelta;
}
return left.command.localeCompare(right.command);
})
);
const mergeSkills = (
existingSkills: ProviderSkill[],
incomingSkills: ProviderSkill[],
): ProviderSkill[] => {
const skillsById = new Map<string, ProviderSkill>();
existingSkills.forEach((skill) => {
skillsById.set(getSkillIdentity(skill), skill);
});
incomingSkills.forEach((skill) => {
skillsById.set(getSkillIdentity(skill), skill);
});
return sortSkills([...skillsById.values()]);
};
const fetchProviderSkills = async (
provider: SkillsProvider,
project?: ProjectTarget,
): Promise<ProviderSkill[]> => {
const params = new URLSearchParams();
if (project?.path) {
params.set('workspacePath', project.path);
}
const response = await authenticatedFetch(
`/api/providers/${provider}/skills${params.toString() ? `?${params.toString()}` : ''}`,
);
const data = await toResponseJson<ApiResponse<ProviderSkillsResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, `Failed to load ${provider} skills`));
}
return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill, project));
};
const saveProviderSkills = async (
provider: SkillsProvider,
payload: ProviderSkillCreatePayload,
): Promise<ProviderSkill[]> => {
const response = await authenticatedFetch(`/api/providers/${provider}/skills`, {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await toResponseJson<ApiResponse<ProviderSkillsResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to save skills'));
}
return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill));
};
const getCacheKey = (provider: SkillsProvider, projects: ProjectTarget[]): string => {
const projectKey = projects.map((project) => project.path).sort().join('|');
return `${provider}:${projectKey}`;
};
const clearProviderSkillCache = (provider: SkillsProvider): void => {
for (const cacheKey of [...skillsCache.keys()]) {
if (cacheKey.startsWith(`${provider}:`)) {
skillsCache.delete(cacheKey);
}
}
};
type UseProviderSkillsArgs = {
selectedProvider: SkillsProvider;
currentProjects: SkillsProject[];
};
export function useProviderSkills({ selectedProvider, currentProjects }: UseProviderSkillsArgs) {
const [skills, setSkills] = useState<ProviderSkill[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const activeLoadIdRef = useRef(0);
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
const cacheKey = useMemo(() => getCacheKey(selectedProvider, projectTargets), [projectTargets, selectedProvider]);
const refreshSkills = useCallback(async (options: { force?: boolean } = {}) => {
const loadId = activeLoadIdRef.current + 1;
activeLoadIdRef.current = loadId;
const cachedEntry = skillsCache.get(cacheKey);
const canUseCache = !options.force && cachedEntry && Date.now() - cachedEntry.updatedAt < SKILLS_CACHE_TTL_MS;
if (canUseCache) {
setSkills(cachedEntry.skills);
setIsLoading(false);
setIsLoadingProjectScopes(false);
setLoadError(null);
return;
}
if (cachedEntry && !options.force) {
setSkills(cachedEntry.skills);
} else {
setSkills([]);
}
setIsLoading(!cachedEntry);
setIsLoadingProjectScopes(false);
setLoadError(null);
let nextSkills = cachedEntry && !options.force ? cachedEntry.skills : [];
let firstError: string | null = null;
try {
const globalSkills = await fetchProviderSkills(selectedProvider);
if (activeLoadIdRef.current !== loadId) {
return;
}
nextSkills = mergeSkills(nextSkills, globalSkills);
setSkills(nextSkills);
} catch (error) {
firstError = error instanceof Error ? error.message : 'Failed to load skills';
}
if (activeLoadIdRef.current !== loadId) {
return;
}
setIsLoading(false);
if (projectTargets.length === 0) {
const finalSkills = sortSkills(nextSkills);
skillsCache.set(cacheKey, { skills: finalSkills, updatedAt: Date.now() });
setSkills(finalSkills);
setLoadError(firstError);
return;
}
setIsLoadingProjectScopes(true);
await Promise.all(projectTargets.map(async (project) => {
try {
const projectSkills = await fetchProviderSkills(selectedProvider, project);
if (activeLoadIdRef.current !== loadId) {
return;
}
nextSkills = mergeSkills(nextSkills, projectSkills);
setSkills(nextSkills);
} catch (error) {
firstError = firstError || (error instanceof Error ? error.message : 'Failed to load skills');
}
}));
if (activeLoadIdRef.current !== loadId) {
return;
}
const finalSkills = sortSkills(nextSkills);
skillsCache.set(cacheKey, { skills: finalSkills, updatedAt: Date.now() });
setSkills(finalSkills);
setLoadError(firstError);
setIsLoadingProjectScopes(false);
}, [cacheKey, projectTargets, selectedProvider]);
const addSkills = useCallback(async (payload: ProviderSkillCreatePayload) => {
try {
const createdSkills = await saveProviderSkills(selectedProvider, payload);
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
setSaveStatus('success');
return createdSkills;
} catch (error) {
setSaveStatus('error');
throw error;
}
}, [refreshSkills, selectedProvider]);
useEffect(() => {
void refreshSkills();
}, [refreshSkills]);
useEffect(() => {
setSaveStatus(null);
}, [selectedProvider]);
useEffect(() => {
if (saveStatus === null) {
return;
}
const timer = window.setTimeout(() => setSaveStatus(null), 6000);
return () => window.clearTimeout(timer);
}, [saveStatus]);
return {
skills,
isLoading,
isLoadingProjectScopes,
loadError,
saveStatus,
addSkills,
refreshSkills,
};
}

View File

@@ -0,0 +1 @@
export { default as ProviderSkills } from './view/ProviderSkills';

View File

@@ -0,0 +1,60 @@
import type { LLMProvider } from '../../types/app';
export type SkillsProvider = LLMProvider;
export type SkillsScope = 'user' | 'project' | 'plugin' | 'repo' | 'admin' | 'system';
export type SkillsProject = {
projectId: string;
displayName?: string;
fullPath?: string;
path?: string;
};
export type ProviderSkill = {
provider: SkillsProvider;
name: string;
description: string;
command: string;
scope: SkillsScope;
sourcePath: string;
pluginName?: string;
pluginId?: string;
projectDisplayName?: string;
projectPath?: string;
};
export type ProviderSkillCreateEntryPayload = {
content: string;
directoryName?: string;
fileName?: string;
files?: Array<{
relativePath: string;
content: string;
encoding: 'base64';
}>;
};
export type ProviderSkillCreatePayload = {
entries: ProviderSkillCreateEntryPayload[];
};
export type ProviderSkillsResponse = {
provider: SkillsProvider;
skills: Array<Partial<ProviderSkill>>;
};
export type ApiSuccessResponse<T> = {
success: true;
data: T;
};
export type ApiErrorResponse = {
success: false;
error?: {
code?: string;
message?: string;
details?: unknown;
};
};
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

View File

@@ -0,0 +1,654 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import {
CheckCircle2,
FileCode2,
FileText,
FileUp,
FolderUp,
Loader2,
RefreshCw,
Search,
Upload,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
} from '../../../shared/view/ui';
import { useProviderSkills } from '../hooks/useProviderSkills';
import type {
ProviderSkill,
ProviderSkillCreateEntryPayload,
SkillsProject,
SkillsProvider,
SkillsScope,
} from '../types';
type ProviderSkillsProps = {
selectedProvider: SkillsProvider;
currentProjects: SkillsProject[];
};
type QueuedSkillSourceFile = {
file: File;
relativePath: string;
};
type QueuedSkillFile = {
id: string;
name: string;
size: number;
kind: 'markdown' | 'folder';
skillFile: File;
files: QueuedSkillSourceFile[];
};
const MAX_SKILL_FOLDER_FILES = 500;
const MAX_SKILL_FOLDER_BYTES = 30 * 1024 * 1024;
const PROVIDER_NAMES: Record<SkillsProvider, string> = {
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
gemini: 'Gemini',
opencode: 'OpenCode',
};
const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string> = {
claude: '~/.claude/skills/<skill-name>/SKILL.md',
codex: '~/.agents/skills/<skill-name>/SKILL.md',
cursor: '~/.cursor/skills/<skill-name>/SKILL.md',
gemini: '~/.gemini/skills/<skill-name>/SKILL.md',
};
const SCOPE_LABELS: Record<SkillsScope, string> = {
user: 'User',
plugin: 'Plugin',
repo: 'Repo',
project: 'Project',
admin: 'Admin',
system: 'System',
};
const SCOPE_BADGE_CLASSES: Record<SkillsScope, string> = {
user: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
plugin: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300',
repo: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
project: 'border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-300',
admin: 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300',
system: 'border-slate-500/30 bg-slate-500/10 text-slate-700 dark:text-slate-300',
};
const SCOPE_ORDER: SkillsScope[] = ['user', 'plugin', 'repo', 'project', 'admin', 'system'];
const groupSkillsByScope = (skills: ProviderSkill[]): Array<{ scope: SkillsScope; skills: ProviderSkill[] }> => (
SCOPE_ORDER
.map((scope) => ({ scope, skills: skills.filter((skill) => skill.scope === scope) }))
.filter((group) => group.skills.length > 0)
);
const formatFileSize = (size: number): string => {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
const getBrowserRelativePath = (file: File): string => {
const fileWithRelativePath = file as File & {
path?: string;
webkitRelativePath?: string;
};
return (
fileWithRelativePath.webkitRelativePath
|| fileWithRelativePath.path
|| file.name
)
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/^\/+/, '');
};
const getParentPath = (filePath: string): string => {
const separatorIndex = filePath.lastIndexOf('/');
return separatorIndex >= 0 ? filePath.slice(0, separatorIndex) : '';
};
const getBaseName = (filePath: string): string => {
const segments = filePath.split('/').filter(Boolean);
return segments.at(-1) || 'skill';
};
const readFileAsBase64 = (file: File): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const separatorIndex = result.indexOf(',');
resolve(separatorIndex >= 0 ? result.slice(separatorIndex + 1) : result);
};
reader.onerror = () => reject(reader.error ?? new Error(`Failed to read ${file.name}`));
reader.readAsDataURL(file);
});
const buildQueuedSkillFolders = (selectedFiles: File[]): QueuedSkillFile[] => {
if (selectedFiles.length > MAX_SKILL_FOLDER_FILES) {
throw new Error(`A skill folder can contain up to ${MAX_SKILL_FOLDER_FILES} files.`);
}
const totalSize = selectedFiles.reduce((size, file) => size + file.size, 0);
if (totalSize > MAX_SKILL_FOLDER_BYTES) {
throw new Error('Selected skill folders must be smaller than 30 MB in total.');
}
const files = selectedFiles.map((file) => ({
file,
relativePath: getBrowserRelativePath(file),
}));
const skillRoots = files
.filter(({ relativePath }) => getBaseName(relativePath).toLowerCase() === 'skill.md')
.map(({ relativePath }) => getParentPath(relativePath))
.sort((left, right) => right.length - left.length);
if (skillRoots.length === 0) {
throw new Error('The selected folder does not contain a SKILL.md file.');
}
return skillRoots.map((skillRoot) => {
const skillFiles = files.filter(({ relativePath }) => {
const owningRoot = skillRoots.find((candidateRoot) => {
const normalizedRelativePath = relativePath.toLowerCase();
const normalizedSkillPath = `${candidateRoot}/skill.md`.toLowerCase();
return normalizedRelativePath === normalizedSkillPath
|| relativePath.startsWith(`${candidateRoot}/`);
});
return owningRoot === skillRoot;
});
const skillSourceFile = skillFiles.find(
({ relativePath }) => (
relativePath.toLowerCase() === `${skillRoot}/skill.md`.toLowerCase()
),
);
if (!skillSourceFile) {
throw new Error(`Could not read SKILL.md from ${getBaseName(skillRoot)}.`);
}
return {
id: `folder:${skillRoot}:${skillFiles.map(({ file }) => file.lastModified).join(':')}`,
name: getBaseName(skillRoot),
size: skillFiles.reduce((size, { file }) => size + file.size, 0),
kind: 'folder' as const,
skillFile: skillSourceFile.file,
files: skillFiles.map(({ file, relativePath }) => ({
file,
relativePath: skillRoot ? relativePath.slice(skillRoot.length + 1) : relativePath,
})),
};
});
};
export default function ProviderSkills({ selectedProvider, currentProjects }: ProviderSkillsProps) {
const { t } = useTranslation('settings');
const {
skills,
isLoading,
isLoadingProjectScopes,
loadError,
saveStatus,
addSkills,
refreshSkills,
} = useProviderSkills({ selectedProvider, currentProjects });
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const providerName = PROVIDER_NAMES[selectedProvider];
const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider];
useEffect(() => {
setQueuedFiles([]);
setSubmitError(null);
setIsSubmitting(false);
setSearchQuery('');
}, [selectedProvider]);
useEffect(() => {
folderInputRef.current?.setAttribute('webkitdirectory', '');
folderInputRef.current?.setAttribute('directory', '');
}, []);
const filteredSkills = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLocaleLowerCase();
if (!normalizedQuery) {
return skills;
}
return skills.filter((skill) => (
[
skill.command,
skill.name,
skill.description,
skill.scope,
skill.pluginName,
skill.projectDisplayName,
skill.sourcePath,
]
.filter(Boolean)
.some((value) => value?.toLocaleLowerCase().includes(normalizedQuery))
));
}, [searchQuery, skills]);
const groupedSkills = useMemo(() => groupSkillsByScope(filteredSkills), [filteredSkills]);
const queueSkillFolders = useCallback((selectedFiles: File[]) => {
const queuedFolders = buildQueuedSkillFolders(selectedFiles);
setQueuedFiles((previous) => {
const nextMap = new Map(previous.map((file) => [file.id, file]));
queuedFolders.forEach((folder) => nextMap.set(folder.id, folder));
return [...nextMap.values()].slice(0, 20);
});
}, []);
const handleDrop = useCallback((files: File[]) => {
const includesDirectory = files.some((file) => getBrowserRelativePath(file).includes('/'));
if (includesDirectory) {
try {
queueSkillFolders(files);
setSubmitError(null);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to read skill folder');
}
return;
}
const acceptedFiles = files
.filter((file) => file.name.toLowerCase().endsWith('.md'))
.slice(0, 20);
if (acceptedFiles.length === 0) {
setSubmitError('Drop one or more markdown files or a folder containing SKILL.md.');
return;
}
setQueuedFiles((previous) => {
const nextMap = new Map(previous.map((file) => [file.id, file]));
acceptedFiles.forEach((file) => {
const id = `${file.name}:${file.size}:${file.lastModified}`;
nextMap.set(id, {
id,
name: file.name,
size: file.size,
kind: 'markdown',
skillFile: file,
files: [{ file, relativePath: 'SKILL.md' }],
});
});
return [...nextMap.values()].slice(0, 20);
});
setSubmitError(null);
}, [queueSkillFolders]);
const handleFolderSelection = useCallback((selectedFiles: File[]) => {
if (selectedFiles.length === 0) {
return;
}
try {
queueSkillFolders(selectedFiles);
setSubmitError(null);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to read skill folder');
}
}, [queueSkillFolders]);
const { getRootProps, isDragActive } = useDropzone({
maxFiles: MAX_SKILL_FOLDER_FILES,
noClick: true,
noKeyboard: true,
onDrop: handleDrop,
});
const handleUploadInstall = useCallback(async () => {
if (queuedFiles.length === 0) {
setSubmitError('Add one or more markdown files first.');
return;
}
setIsSubmitting(true);
setSubmitError(null);
try {
const entries = await Promise.all<ProviderSkillCreateEntryPayload>(queuedFiles.map(async (queuedFile) => ({
fileName: queuedFile.kind === 'folder' ? `${queuedFile.name}.md` : queuedFile.name,
directoryName: queuedFile.kind === 'folder' ? queuedFile.name : undefined,
content: await queuedFile.skillFile.text(),
files: queuedFile.kind === 'folder'
? await Promise.all(
queuedFile.files
.filter(({ relativePath }) => relativePath.toLowerCase() !== 'skill.md')
.map(async ({ file, relativePath }) => ({
relativePath,
content: await readFileAsBase64(file),
encoding: 'base64' as const,
})),
)
: undefined,
})));
await addSkills({ entries });
setQueuedFiles([]);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
} finally {
setIsSubmitting(false);
}
}, [addSkills, queuedFiles]);
return (
<div className="min-w-0 space-y-4 overflow-x-hidden">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
<FileCode2 className="h-4 w-4" strokeWidth={1.7} />
</div>
<div className="min-w-0 space-y-1">
<h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
<p className="text-sm text-muted-foreground">
Install global {providerName} skills from `.md` files or complete skill folders.
</p>
</div>
</div>
<Button
onClick={() => void refreshSkills({ force: true })}
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={isLoading || isLoadingProjectScopes}
>
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
Refresh
</Button>
</div>
<Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="text-sm font-medium text-foreground">Upload Skills</div>
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/70 p-3">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="space-y-4">
<div
{...getRootProps()}
className={cn(
'rounded-3xl border border-dashed p-4 transition-colors sm:p-5',
isDragActive
? 'border-foreground/40 bg-muted/35'
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
)}
>
<input
ref={fileInputRef}
type="file"
accept=".md,text/markdown"
multiple
className="hidden"
onChange={(event) => {
handleDrop(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
handleFolderSelection(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center sm:py-6">
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
<div className="text-sm text-muted-foreground">
Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FileUp className="h-4 w-4" />
Choose Files
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => folderInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FolderUp className="h-4 w-4" />
Choose Folder
</Button>
</div>
</div>
</div>
{queuedFiles.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Queued Files</div>
<div className="grid gap-2">
{queuedFiles.map((queuedFile) => (
<div
key={queuedFile.id}
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
<div className="text-xs text-muted-foreground">
{queuedFile.kind === 'folder'
? `${queuedFile.files.length} files`
: 'Markdown file'}
{' · '}
{formatFileSize(queuedFile.size)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full sm:w-auto"
onClick={() => {
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
}}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Button
type="button"
onClick={() => void handleUploadInstall()}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
</Button>
<span className="text-xs text-muted-foreground">
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
</span>
</div>
</div>
{(submitError || loadError) && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{submitError || loadError}
</div>
)}
{saveStatus === 'success' && (
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4" />
Skills saved successfully.
</div>
)}
</CardContent>
</Card>
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
<CardHeader className="border-b border-border/60">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<CardTitle>Visible Skills</CardTitle>
<CardDescription>
The list below comes from the provider skill discovery API and includes global and project-aware locations.
</CardDescription>
</div>
<div className="relative w-full lg:w-72">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search skills..."
aria-label="Search visible skills"
className="h-9 w-full pl-9 pr-9"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
aria-label="Clear skill search"
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{isLoadingProjectScopes && (
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Scanning project skills
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-5 p-4">
{isLoading && skills.length === 0 && (
<div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground">
Loading {providerName} skills
</div>
)}
{!isLoading && skills.length === 0 && (
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-muted-foreground">
<FileText className="h-6 w-6" />
</div>
<div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div>
<div className="mt-1 text-sm text-muted-foreground">
Add a global skill above or create project-specific skill folders in your workspace.
</div>
</div>
)}
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-3 text-sm font-medium text-foreground">No matching skills</div>
<div className="mt-1 text-sm text-muted-foreground">
Try a different command, name, scope, project, or source path.
</div>
</div>
)}
{groupedSkills.map((group) => (
<section key={group.scope} className="min-w-0 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className={cn('rounded-full px-2.5 py-1 text-xs', SCOPE_BADGE_CLASSES[group.scope])}>
{SCOPE_LABELS[group.scope]}
</Badge>
<span className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
</span>
</div>
<div className="grid min-w-0 gap-3 lg:grid-cols-2">
{group.skills.map((skill) => (
<div
key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`}
className="min-w-0 rounded-3xl border border-border/70 bg-gradient-to-br from-background via-background to-muted/25 p-4 shadow-sm"
>
<div className="min-w-0 space-y-1">
<div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div>
<div className="text-sm text-muted-foreground">{skill.name}</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{skill.description || 'No description provided in the skill front matter.'}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
{skill.pluginName && (
<Badge variant="outline" className="rounded-full bg-background/70">
Plugin: {skill.pluginName}
</Badge>
)}
{skill.projectDisplayName && (
<Badge variant="outline" className="rounded-full bg-background/70">
Project: {skill.projectDisplayName}
</Badge>
)}
</div>
<div className="mt-4 min-w-0 rounded-2xl border border-border/60 bg-muted/20 px-3 py-2">
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code>
</div>
</div>
))}
</div>
</section>
))}
</CardContent>
</Card>
</div>
);
}

View File

@@ -28,20 +28,31 @@ export const useVersionCheck = (owner: string, repo: string) => {
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null);
const [installMode, setInstallMode] = useState<InstallMode>('git');
const [runningVersion, setRunningVersion] = useState<string | null>(null);
const [restartRequired, setRestartRequired] = useState(false);
useEffect(() => {
const fetchInstallMode = async () => {
const fetchHealth = async () => {
try {
const response = await fetch('/health');
const data = await response.json();
if (data.installMode === 'npm' || data.installMode === 'git') {
setInstallMode(data.installMode);
}
// `data.version` is the version the server process is actually running.
// This module's `version` is baked into the frontend bundle at build
// time, so it reflects the installed (on-disk) package. If they differ,
// the package was updated but the server process was not restarted, and
// DB-backed actions may silently fail until it is.
if (typeof data.version === 'string' && data.version.length > 0) {
setRunningVersion(data.version);
setRestartRequired(data.version !== version);
}
} catch {
// Default to git on error
// Default to git / no restart hint on error
}
};
fetchInstallMode();
fetchHealth();
}, []);
useEffect(() => {
@@ -84,5 +95,5 @@ export const useVersionCheck = (owner: string, repo: string) => {
return () => clearInterval(interval);
}, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode };
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode, runningVersion, restartRequired };
};

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Fehler beim Wiederherstellen der Sitzung. Bitte erneut versuchen."
},
"version": {
"updateAvailable": "Update verfügbar"
"updateAvailable": "Update verfügbar",
"restartRequired": "Update installiert zum Anwenden Server neu starten"
},
"search": {
"modeProjects": "Projekte",

View File

@@ -4,6 +4,7 @@
"account": "Account",
"permissions": "Permissions",
"mcpServers": "MCP Servers",
"skills": "Skills",
"appearance": "Appearance"
},
"account": {

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Error restoring session. Please try again."
},
"version": {
"updateAvailable": "Update available"
"updateAvailable": "Update available",
"restartRequired": "Update installed — restart the server to apply"
},
"search": {
"modeProjects": "Projects",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Erreur lors de la restauration de la session. Veuillez réessayer."
},
"version": {
"updateAvailable": "Mise à jour disponible"
"updateAvailable": "Mise à jour disponible",
"restartRequired": "Mise à jour installée — redémarrez le serveur pour l'appliquer"
},
"search": {
"modeProjects": "Projets",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Errore durante il ripristino della sessione. Riprova."
},
"version": {
"updateAvailable": "Aggiornamento disponibile"
"updateAvailable": "Aggiornamento disponibile",
"restartRequired": "Aggiornamento installato — riavvia il server per applicarlo"
},
"search": {
"modeProjects": "Progetti",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "セッションの復元でエラーが発生しました。もう一度お試しください。"
},
"version": {
"updateAvailable": "アップデートあり"
"updateAvailable": "アップデートあり",
"restartRequired": "更新が適用されていません。サーバーを再起動してください"
},
"deleteConfirmation": {
"deleteProject": "プロジェクトを除去",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "세션 복원 오류. 다시 시도해주세요."
},
"version": {
"updateAvailable": "업데이트 가능"
"updateAvailable": "업데이트 가능",
"restartRequired": "업데이트가 설치됨 — 적용하려면 서버를 재시작하세요"
},
"deleteConfirmation": {
"deleteProject": "프로젝트 제거",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Ошибка при восстановлении сеанса. Попробуйте снова."
},
"version": {
"updateAvailable": "Доступно обновление"
"updateAvailable": "Доступно обновление",
"restartRequired": "Обновление установлено — перезапустите сервер для применения"
},
"search": {
"modeProjects": "Проекты",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Oturum geri yüklenirken hata oluştu. Lütfen tekrar dene."
},
"version": {
"updateAvailable": "Güncelleme mevcut"
"updateAvailable": "Güncelleme mevcut",
"restartRequired": "Güncelleme yüklendi — uygulamak için sunucuyu yeniden başlatın"
},
"search": {
"modeProjects": "Projeler",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "恢复会话时出错,请重试。"
},
"version": {
"updateAvailable": "有可用更新"
"updateAvailable": "有可用更新",
"restartRequired": "已安装更新 — 请重启服务器以生效"
},
"search": {
"modeProjects": "项目",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "還原工作階段時出錯,請重試。"
},
"version": {
"updateAvailable": "有可用更新"
"updateAvailable": "有可用更新",
"restartRequired": "已安裝更新 — 請重新啟動伺服器以套用"
},
"search": {
"modeProjects": "專案",