tools and skills, etc in the containers
This commit is contained in:
@@ -14,7 +14,7 @@ type CapabilitySummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: 'global' | 'user';
|
||||
scope: 'native' | 'global' | 'user';
|
||||
};
|
||||
|
||||
export type CapabilityDetail = CapabilitySummary & {
|
||||
|
||||
@@ -3,24 +3,28 @@ import { readdirSync, existsSync, mkdirSync } from "node:fs";
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readApiKeys } from "../server-settings/pi-mono";
|
||||
import { readSearxngConfig } from "../server-settings/searxng";
|
||||
import { PI_CONFIG_DIR, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir } from "../../data-path";
|
||||
import { ensureDockerContainer } from "../terminal/websocket";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export type PiEventHandler = (event: PiEvent) => void;
|
||||
|
||||
function collectSkillFlags(email: string): string[] {
|
||||
const flags: string[] = [];
|
||||
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
|
||||
type PathOverrides = { global: string; user: string };
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
function collectSkillFlags(email: string, containerPaths?: PathOverrides): string[] {
|
||||
const flags: string[] = [];
|
||||
const pairs: Array<[hostDir: string, outputDir: string]> = [
|
||||
[getGlobalSkillsDir(), containerPaths?.global ?? getGlobalSkillsDir()],
|
||||
[getUserSkillsDir(email), containerPaths?.user ?? getUserSkillsDir(email)],
|
||||
];
|
||||
|
||||
for (const [hostDir, outputDir] of pairs) {
|
||||
if (!existsSync(hostDir)) continue;
|
||||
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillFile = join(dir, entry.name, 'SKILL.md');
|
||||
if (existsSync(skillFile)) {
|
||||
flags.push('--skill', join(dir, entry.name));
|
||||
if (existsSync(join(hostDir, entry.name, 'SKILL.md'))) {
|
||||
flags.push('--skill', `${outputDir}/${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,18 +32,19 @@ function collectSkillFlags(email: string): string[] {
|
||||
return flags;
|
||||
}
|
||||
|
||||
function collectExtensionFlags(email: string): string[] {
|
||||
function collectExtensionFlags(email: string, containerPaths?: PathOverrides): string[] {
|
||||
const flags: string[] = [];
|
||||
const dirs = [getGlobalExtensionsDir(), getUserExtensionsDir(email)];
|
||||
const pairs: Array<[hostDir: string, outputDir: string]> = [
|
||||
[getGlobalExtensionsDir(), containerPaths?.global ?? getGlobalExtensionsDir()],
|
||||
[getUserExtensionsDir(email), containerPaths?.user ?? getUserExtensionsDir(email)],
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
for (const [hostDir, outputDir] of pairs) {
|
||||
if (!existsSync(hostDir)) continue;
|
||||
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const entryFile = join(dir, entry.name, 'index.ts');
|
||||
if (existsSync(entryFile)) {
|
||||
flags.push('--extension', entryFile);
|
||||
if (existsSync(join(hostDir, entry.name, 'index.ts'))) {
|
||||
flags.push('--extension', `${outputDir}/${entry.name}/index.ts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,17 +71,35 @@ export async function spawnPi(
|
||||
if (sandbox) {
|
||||
const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username);
|
||||
const storedKeys = await readApiKeys();
|
||||
const searxng = await readSearxngConfig();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const containerId = container.dockerId;
|
||||
const containerHome = `/home/${sandbox.username}`;
|
||||
const containerPiConfig = `${containerHome}/.pi/agent`;
|
||||
const piArgs = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||
|
||||
// Collect skill/extension flags using container-side paths
|
||||
const skillFlags = collectSkillFlags(sandbox.email, {
|
||||
global: '/officer/skills',
|
||||
user: '/officer/user/skills',
|
||||
});
|
||||
const extensionFlags = collectExtensionFlags(sandbox.email, {
|
||||
global: '/officer/extensions',
|
||||
user: '/officer/user/extensions',
|
||||
});
|
||||
|
||||
const piArgs = [
|
||||
'pi', '--mode', 'rpc',
|
||||
'--no-skills', '--no-prompt-templates', '--no-themes',
|
||||
...skillFlags,
|
||||
...extensionFlags,
|
||||
];
|
||||
if (model) piArgs.push('--model', model);
|
||||
|
||||
// Build env flags: Pi config dir + all stored API keys
|
||||
const envFlags = [
|
||||
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
|
||||
'-e', `HOME=${containerHome}`,
|
||||
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
|
||||
'-e', `PI_SEARXNG_URL=${searxng.url}`,
|
||||
];
|
||||
for (const [key, value] of Object.entries(storedKeys)) {
|
||||
if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`);
|
||||
@@ -96,9 +119,15 @@ export async function spawnPi(
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi in container', { containerId, model });
|
||||
logger.info('Spawned Pi in container', {
|
||||
containerId,
|
||||
model,
|
||||
skills: skillFlags.filter((f) => f !== '--skill').length,
|
||||
extensions: extensionFlags.filter((f) => f !== '--extension').length,
|
||||
});
|
||||
} else {
|
||||
const storedKeys = await readApiKeys();
|
||||
const searxng = await readSearxngConfig();
|
||||
const skillFlags = collectSkillFlags(email);
|
||||
const extensionFlags = collectExtensionFlags(email);
|
||||
const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags];
|
||||
@@ -115,7 +144,7 @@ export async function spawnPi(
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs },
|
||||
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url },
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi locally', {
|
||||
|
||||
@@ -53,6 +53,10 @@ function resolveFile(name: string, native: Map<string, string>, global: Map<stri
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPrivileged(role: string) {
|
||||
return role === 'Admin' || role === 'Owner' || role === 'Super Admin';
|
||||
}
|
||||
|
||||
export const processesRouter = createRouter();
|
||||
|
||||
processesRouter.get('/', async (ctx) => {
|
||||
@@ -134,6 +138,8 @@ processesRouter.put('/:name/chat', async (ctx) => {
|
||||
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
|
||||
|
||||
@@ -155,6 +161,8 @@ processesRouter.delete('/:name/chat', async (ctx) => {
|
||||
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
await rm(chatDir, { recursive: true, force: true });
|
||||
|
||||
@@ -162,13 +170,17 @@ processesRouter.delete('/:name/chat', async (ctx) => {
|
||||
});
|
||||
|
||||
processesRouter.post('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const { name } = await ctx.req.json<{ name: string }>();
|
||||
if (!name?.trim()) return ctx.text('Name is required', 400);
|
||||
|
||||
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
if (!dirName) return ctx.text('Invalid name', 400);
|
||||
|
||||
const dir = join(getGlobalProcessesDir(), dirName);
|
||||
const targetDir = isPrivileged(user.role) ? getGlobalProcessesDir() : getUserProcessesDir(user.email);
|
||||
const scope: Scope = isPrivileged(user.role) ? 'global' : 'user';
|
||||
|
||||
const dir = join(targetDir, dirName);
|
||||
const filePath = join(dir, 'PROCESS.md');
|
||||
|
||||
if (await Bun.file(filePath).exists()) {
|
||||
@@ -178,18 +190,22 @@ processesRouter.post('/', async (ctx) => {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
|
||||
return ctx.json({ name: name.trim(), dirName, filePath });
|
||||
return ctx.json({ name: name.trim(), dirName, filePath, scope });
|
||||
});
|
||||
|
||||
processesRouter.delete('/:name', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
const globalDir = join(getGlobalProcessesDir(), name);
|
||||
const globalFile = join(globalDir, 'PROCESS.md');
|
||||
|
||||
if (!(await Bun.file(globalFile).exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
|
||||
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
|
||||
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
|
||||
|
||||
await rm(globalDir, { recursive: true });
|
||||
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403);
|
||||
|
||||
await rm(dirname(resolved.filePath), { recursive: true });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
|
||||
export const searxngRouter = createRouter();
|
||||
|
||||
const SEARXNG_FILE = join(DATA_PATH, 'searxng.json');
|
||||
|
||||
const DEFAULT_URL = 'https://searxng.home.pastilhas.eu';
|
||||
|
||||
export type SearxngConfig = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function readSearxngConfig(): Promise<SearxngConfig> {
|
||||
try {
|
||||
const file = Bun.file(SEARXNG_FILE);
|
||||
if (!(await file.exists())) return { url: DEFAULT_URL };
|
||||
return (await file.json()) as SearxngConfig;
|
||||
} catch {
|
||||
return { url: DEFAULT_URL };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSearxngConfig(config: SearxngConfig) {
|
||||
await Bun.write(SEARXNG_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
searxngRouter.get('/', async (ctx) => {
|
||||
return ctx.json(await readSearxngConfig());
|
||||
});
|
||||
|
||||
searxngRouter.put('/', async (ctx) => {
|
||||
const { url } = await ctx.req.json<{ url: string }>();
|
||||
if (!url?.trim()) return ctx.json({ error: 'URL is required' }, 400);
|
||||
const config: SearxngConfig = { url: url.trim().replace(/\/+$/, '') };
|
||||
await writeSearxngConfig(config);
|
||||
return ctx.json(config);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { smtpRouter } from './smtp';
|
||||
import { ttsRouter } from './tts';
|
||||
import { sttRouter } from './stt';
|
||||
import { ocrRouter } from './ocr';
|
||||
import { searxngRouter } from './searxng';
|
||||
|
||||
const configDir = `${homedir()}/.config/officer.dev`;
|
||||
export const settingsPath = `${configDir}/server-settings.json`;
|
||||
@@ -33,6 +34,7 @@ serverSettingsRouter.route('/smtp', smtpRouter);
|
||||
serverSettingsRouter.route('/tts', ttsRouter);
|
||||
serverSettingsRouter.route('/stt', sttRouter);
|
||||
serverSettingsRouter.route('/ocr', ocrRouter);
|
||||
serverSettingsRouter.route('/searxng', searxngRouter);
|
||||
|
||||
const readSettings = async () => {
|
||||
try { return await Bun.file(settingsPath).json(); } catch { return {}; }
|
||||
|
||||
@@ -40,6 +40,10 @@ export async function readSkillDirs(dir: string): Promise<Map<string, string>> {
|
||||
|
||||
type Scope = 'native' | 'global' | 'user';
|
||||
|
||||
function isPrivileged(role: string) {
|
||||
return role === 'Admin' || role === 'Owner' || role === 'Super Admin';
|
||||
}
|
||||
|
||||
function resolveScope(dirName: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): Scope {
|
||||
if (user.has(dirName)) return 'user';
|
||||
if (global.has(dirName)) return 'global';
|
||||
@@ -134,6 +138,8 @@ skillsRouter.put('/:name/chat', async (ctx) => {
|
||||
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
|
||||
|
||||
@@ -155,6 +161,8 @@ skillsRouter.delete('/:name/chat', async (ctx) => {
|
||||
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
await rm(chatDir, { recursive: true, force: true });
|
||||
|
||||
@@ -162,13 +170,18 @@ skillsRouter.delete('/:name/chat', async (ctx) => {
|
||||
});
|
||||
|
||||
skillsRouter.post('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const { name } = await ctx.req.json<{ name: string }>();
|
||||
if (!name?.trim()) return ctx.text('Name is required', 400);
|
||||
|
||||
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
if (!dirName) return ctx.text('Invalid name', 400);
|
||||
|
||||
const dir = join(getGlobalSkillsDir(), dirName);
|
||||
// Members save to their own scope; Admins and above save to global
|
||||
const targetDir = isPrivileged(user.role) ? getGlobalSkillsDir() : getUserSkillsDir(user.email);
|
||||
const scope: Scope = isPrivileged(user.role) ? 'global' : 'user';
|
||||
|
||||
const dir = join(targetDir, dirName);
|
||||
const filePath = join(dir, 'SKILL.md');
|
||||
|
||||
if (await Bun.file(filePath).exists()) {
|
||||
@@ -178,18 +191,24 @@ skillsRouter.post('/', async (ctx) => {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
|
||||
return ctx.json({ name: name.trim(), dirName, filePath });
|
||||
return ctx.json({ name: name.trim(), dirName, filePath, scope });
|
||||
});
|
||||
|
||||
skillsRouter.delete('/:name', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
const globalDir = join(getGlobalSkillsDir(), name);
|
||||
const globalFile = join(globalDir, 'SKILL.md');
|
||||
|
||||
if (!(await Bun.file(globalFile).exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
|
||||
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
|
||||
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
|
||||
|
||||
await rm(globalDir, { recursive: true });
|
||||
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
// Members can only delete their own skills
|
||||
if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403);
|
||||
|
||||
const dir = dirname(resolved.filePath);
|
||||
await rm(dir, { recursive: true });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body:
|
||||
const triggers: TriggerConfig[] = [];
|
||||
const triggerMatch = yaml.match(/^trigger:\s*\n((?:[ \t]+.+\n?)*)/m);
|
||||
if (triggerMatch) {
|
||||
// Split on top-level list items (lines starting with " - type:")
|
||||
const items = triggerMatch[1]!.split(/(?=^\s+-\s*type:)/m);
|
||||
for (const item of items) {
|
||||
const type = item.match(/type:\s*(.+)/)?.[1]?.trim();
|
||||
@@ -73,6 +72,10 @@ function resolveFile(name: string, native: Map<string, string>, global: Map<stri
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPrivileged(role: string) {
|
||||
return role === 'Admin' || role === 'Owner' || role === 'Super Admin';
|
||||
}
|
||||
|
||||
export const tasksRouter = createRouter();
|
||||
|
||||
tasksRouter.get('/', async (ctx) => {
|
||||
@@ -161,6 +164,8 @@ tasksRouter.put('/:name/chat', async (ctx) => {
|
||||
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
|
||||
|
||||
@@ -182,6 +187,8 @@ tasksRouter.delete('/:name/chat', async (ctx) => {
|
||||
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403);
|
||||
|
||||
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||
await rm(chatDir, { recursive: true, force: true });
|
||||
|
||||
@@ -189,13 +196,17 @@ tasksRouter.delete('/:name/chat', async (ctx) => {
|
||||
});
|
||||
|
||||
tasksRouter.post('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const { name } = await ctx.req.json<{ name: string }>();
|
||||
if (!name?.trim()) return ctx.text('Name is required', 400);
|
||||
|
||||
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
if (!dirName) return ctx.text('Invalid name', 400);
|
||||
|
||||
const dir = join(getGlobalTasksDir(), dirName);
|
||||
const targetDir = isPrivileged(user.role) ? getGlobalTasksDir() : getUserTasksDir(user.email);
|
||||
const scope: Scope = isPrivileged(user.role) ? 'global' : 'user';
|
||||
|
||||
const dir = join(targetDir, dirName);
|
||||
const filePath = join(dir, 'TASK.md');
|
||||
|
||||
if (await Bun.file(filePath).exists()) {
|
||||
@@ -205,18 +216,22 @@ tasksRouter.post('/', async (ctx) => {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
|
||||
|
||||
return ctx.json({ name: name.trim(), dirName, filePath });
|
||||
return ctx.json({ name: name.trim(), dirName, filePath, scope });
|
||||
});
|
||||
|
||||
tasksRouter.delete('/:name', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const name = ctx.req.param('name');
|
||||
const globalDir = join(getGlobalTasksDir(), name);
|
||||
const globalFile = join(globalDir, 'TASK.md');
|
||||
|
||||
if (!(await Bun.file(globalFile).exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
const nativeTasks = await readTaskDirs(getNativeTasksDir());
|
||||
const globalTasks = await readTaskDirs(getGlobalTasksDir());
|
||||
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
|
||||
|
||||
await rm(globalDir, { recursive: true });
|
||||
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
|
||||
if (!resolved) return ctx.text('Not found', 404);
|
||||
|
||||
if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403);
|
||||
|
||||
await rm(dirname(resolved.filePath), { recursive: true });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun';
|
||||
import { mkdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir } from '@@/data-path';
|
||||
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
||||
import { getUsers } from 'officerdb';
|
||||
|
||||
@@ -101,7 +101,20 @@ const ensureDockerImage = () => {
|
||||
dockerImageReady = true;
|
||||
};
|
||||
|
||||
const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string): { dockerId: string } => {
|
||||
// Check whether a container already has the officer resource mounts.
|
||||
// We test for the global skills dir as a proxy for all mounts being present.
|
||||
const containerHasResourceMounts = (dockerId: string): boolean => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [dockerPath, 'inspect', '--format', '{{range .Mounts}}{{.Source}}\n{{end}}', dockerId],
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
if (result.exitCode !== 0) return false;
|
||||
return result.stdout.toString().includes(getGlobalSkillsDir());
|
||||
};
|
||||
|
||||
const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string, email: string): { dockerId: string } => {
|
||||
ensureDockerImage();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const dockerId = `officer-terminal-${userId}`;
|
||||
@@ -144,10 +157,13 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, usern
|
||||
`TERMINAL_UID=${uid}`,
|
||||
'-e',
|
||||
`TERMINAL_GID=${gid}`,
|
||||
'-v',
|
||||
`${homeDir}:${containerHome}`,
|
||||
'-w',
|
||||
containerHome,
|
||||
'-v', `${homeDir}:${containerHome}`,
|
||||
'-v', `${getGlobalSkillsDir()}:/officer/skills:ro`,
|
||||
'-v', `${getGlobalToolsDir()}:/officer/tools:ro`,
|
||||
'-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`,
|
||||
'-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`,
|
||||
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
|
||||
'-w', containerHome,
|
||||
tag,
|
||||
],
|
||||
stdout: 'inherit',
|
||||
@@ -211,17 +227,35 @@ const dockerStart = (dockerId: string) => {
|
||||
};
|
||||
|
||||
export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => {
|
||||
// Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing)
|
||||
mkdirSync(getUserSkillsDir(email), { recursive: true });
|
||||
mkdirSync(getUserToolsDir(email), { recursive: true });
|
||||
|
||||
const map = await loadContainerMap();
|
||||
const existing = map[email];
|
||||
if (existing && dockerContainerRunning(existing.dockerId)) return existing;
|
||||
|
||||
if (existing && dockerContainerRunning(existing.dockerId)) {
|
||||
// Recreate if resource mounts are missing (e.g. first run after feature was added)
|
||||
if (!containerHasResourceMounts(existing.dockerId)) {
|
||||
console.log(`[terminal] recreating container for ${email} — resource mounts missing`);
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing && dockerContainerExists(existing.dockerId)) {
|
||||
if (dockerStart(existing.dockerId)) return existing;
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
if (!containerHasResourceMounts(existing.dockerId)) {
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else if (dockerStart(existing.dockerId)) {
|
||||
return existing;
|
||||
} else {
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
}
|
||||
}
|
||||
|
||||
const port = existing?.port ?? getAvailablePort(map, userId);
|
||||
const docker = startDockerSidecar(port, homeDir, userId, username);
|
||||
const docker = startDockerSidecar(port, homeDir, userId, username, email);
|
||||
const next = { userId, email, dockerId: docker.dockerId, port };
|
||||
map[email] = next;
|
||||
await saveContainerMap(map);
|
||||
|
||||
@@ -4,6 +4,30 @@ import rehypeRaw from 'rehype-raw';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { ToolActivity } from './ToolActivity';
|
||||
import { QuestionActivity } from './QuestionActivity';
|
||||
import { getRawUrl } from '../../FileViewer/file-types';
|
||||
|
||||
// Matches absolute image file paths, e.g. /home/user/pic.png or /tmp/photo.jpg
|
||||
const IMAGE_PATH_RE = /(\/(?:home\/[^/\s]+\/)?[^\s`"'<>\n\r[\]()]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|ico))/gi;
|
||||
|
||||
function pathToImageUrl(filePath: string): string {
|
||||
// Strip /home/{username} prefix — the file browser root=home serves from the user's home dir
|
||||
const relative = filePath.replace(/^\/home\/[^/]+/, '') || '/';
|
||||
return getRawUrl(relative, 'home');
|
||||
}
|
||||
|
||||
function injectImages(text: string): string {
|
||||
// Split on fenced code blocks and inline code so we don't touch paths inside backticks
|
||||
const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`)/g);
|
||||
return parts
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) return part; // inside code — leave untouched
|
||||
return part.replace(IMAGE_PATH_RE, (match) => {
|
||||
const filename = match.split('/').pop() ?? 'image';
|
||||
return `})`;
|
||||
});
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
const FRONTMATTER_RE = /^<frontmatter>([\s\S]*?)<\/frontmatter>\s*/;
|
||||
|
||||
@@ -56,7 +80,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
|
||||
<div className="chat-md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{message.text}
|
||||
{injectImages(message.text)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +124,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
|
||||
<div className="chat-md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{text}
|
||||
{injectImages(text)}
|
||||
</ReactMarkdown>
|
||||
<span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user