tools and skills, etc in the containers

This commit is contained in:
2026-02-24 01:24:28 +00:00
parent 071e2decc3
commit d6ffe43a11
16 changed files with 996 additions and 66 deletions
@@ -14,7 +14,7 @@ type CapabilitySummary = {
dirName: string;
name: string;
description: string;
scope: 'global' | 'user';
scope: 'native' | 'global' | 'user';
};
export type CapabilityDetail = CapabilitySummary & {
+52 -23
View File
@@ -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', {
+24 -8
View File
@@ -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 {}; }
+27 -8
View File
@@ -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 -9
View File
@@ -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 });
});
+44 -10
View File
@@ -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 `![${filename}](${pathToImageUrl(match)})`;
});
})
.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>