ompchamber 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -45,7 +45,6 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@fontsource/fira-code": "^5.3.0",
48
- "@google/genai": "^2.21.0",
49
48
  "@shikijs/langs": "^4.4.3",
50
49
  "@shikijs/themes": "^4.4.3",
51
50
  "@tailwindcss/typography": "^0.5.20",
@@ -216,6 +216,7 @@ ENVIRONMENT:
216
216
  OMPCHAMBER_DATA_DIR Override the data directory (default: ~/.ompchamber)
217
217
  OMPCHAMBER_PORT Default web server port
218
218
  OMPCHAMBER_HOST Default bind address
219
+ OMPCHAMBER_OMP_BIN Path to the omp binary (required; falls back to PATH)
219
220
  GITHUB_TOKEN Raise the GitHub API rate limit for update checks
220
221
 
221
222
  EXAMPLES:
@@ -10,6 +10,7 @@ import {
10
10
  } from '@/cli/lib/runtime.js';
11
11
  import { log, ok, warn, fail, printJson, isJson, isQuiet } from '@/cli/lib/output.js';
12
12
  import { wireChildProcessLifecycle } from '@/cli/lib/process-lifecycle.js';
13
+ import { ompStartupError } from '@/server/lib/omp/core/startup';
13
14
 
14
15
  const DEFAULT_PORT = 3000;
15
16
  const DEFAULT_HOST = '127.0.0.1';
@@ -45,6 +46,12 @@ export async function run(options, ctx) {
45
46
  const quiet = isQuiet();
46
47
  const pkgRoot = ctx?.pkgRoot ?? process.cwd();
47
48
 
49
+ // The same gate the server entry applies, run here first so a missing omp
50
+ // binary fails immediately — with no detached child spawned, no log file to
51
+ // read and no registry entry left pointing at a process that already exited.
52
+ const ompError = ompStartupError();
53
+ if (ompError) fail(ompError);
54
+
48
55
  if (mode === 'prod') {
49
56
  const serverEntry = joinPath(pkgRoot, 'dist', 'client', 'index.html');
50
57
  // Bun.file().exists() is file-only (false for directories) — correct here,
@@ -5,12 +5,16 @@ import { useFetcher } from '@/client/lib/router/fetcher';
5
5
  import { FileIcon } from '@/client/components/common/file-icon';
6
6
  import { FileContextMenu, FileDeleteModal, FileHistoryModal, FileRenameModal } from '@/client/components/workspace/file-explorer/Modals';
7
7
  import { getGitStatusInfo, type FolderGitStatusInfo } from '@/shared/lib/fs/git-status';
8
+ import { toAbsolutePath } from '@/shared/lib/fs/paths';
9
+ import { copyToClipboard } from '@/client/hooks/ui/clipboard';
8
10
  import type { GitChange } from '@/shared/types/git';
9
11
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
10
12
 
11
13
  interface FileTreeItemProps {
12
14
  file: any;
13
15
  rootPath?: string;
16
+ /** Absolute base dir of the listing (server-reported `root`), anchors Copy Path. */
17
+ basePath?: string;
14
18
  repo?: string;
15
19
  onLoadChildren?: (path: string) => Promise<void>;
16
20
  onOpenFile?: (file: any) => void;
@@ -24,6 +28,7 @@ interface FileTreeItemProps {
24
28
  export function FileTreeItem({
25
29
  file,
26
30
  rootPath,
31
+ basePath,
27
32
  repo,
28
33
  onLoadChildren,
29
34
  onOpenFile,
@@ -151,9 +156,9 @@ export function FileTreeItem({
151
156
  fd.append('path', file.path);
152
157
  submitAction(fd);
153
158
  } else if (actionType === 'copy_path') {
154
- navigator.clipboard.writeText('/app/applet/examples/' + file.path);
159
+ void copyToClipboard(toAbsolutePath(basePath, file.path));
155
160
  } else if (actionType === 'copy_relative') {
156
- navigator.clipboard.writeText(file.path);
161
+ void copyToClipboard(file.path);
157
162
  } else if (actionType === 'history') {
158
163
  setShowHistoryModal(true);
159
164
  const fd = new FormData();
@@ -236,6 +241,7 @@ export function FileTreeItem({
236
241
  key={child.id}
237
242
  file={child}
238
243
  rootPath={rootPath}
244
+ basePath={basePath}
239
245
  repo={repo}
240
246
  onLoadChildren={onLoadChildren}
241
247
  onOpenFile={onOpenFile}
@@ -10,6 +10,10 @@ import { usePanelRefresh, useFileMutationRefresh } from '@/client/hooks/workspac
10
10
 
11
11
  export function FileExplorer({ className = '', enabled = true, rootPath, onOpenFile, refreshKey = 0, onRefresh }: { className?: string, enabled?: boolean, rootPath?: string, onOpenFile?: (file: any) => void, refreshKey?: number, onRefresh?: () => void }) {
12
12
  const [tree, setTree] = useState<any[]>([]);
13
+ // Absolute base dir reported by `/api/fs/dir` for the current listing — the
14
+ // workspace root, or the selected nested repo. Copy Path anchors on it
15
+ // because the client's `rootPath` may be rejected/unset.
16
+ const [listingRoot, setListingRoot] = useState('');
13
17
  const [searchQuery, setSearchQuery] = useSessionState<string>('files.searchQuery', '');
14
18
  const [isLoading, setIsLoading] = useState(false);
15
19
  const [storedExpandedPaths, setStoredExpandedPaths, expandedPathsReady] = useSessionState<string[]>('files.expandedPaths', []);
@@ -43,6 +47,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
43
47
  fetch(listUrl())
44
48
  .then(r => r.json())
45
49
  .then(data => {
50
+ if (typeof data.root === 'string') setListingRoot(data.root);
46
51
  if (Array.isArray(data.files)) {
47
52
  setTree(rehydrateTree(data.files, childrenCacheRef.current, expandedPaths));
48
53
  }
@@ -176,6 +181,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
176
181
  key={file.id}
177
182
  file={file}
178
183
  rootPath={rootPath}
184
+ basePath={listingRoot}
179
185
  repo={activeRepo}
180
186
  onLoadChildren={loadChildren}
181
187
  onOpenFile={onOpenFile}
@@ -1,5 +1,13 @@
1
1
  import type { AIModelOption } from '@/shared/types';
2
2
 
3
+ /**
4
+ * MOCK-only demo catalog: the model rows a `MOCK=true` chamber shows before the
5
+ * operator adds any of their own.
6
+ *
7
+ * Real mode never reads this — `/api/models` there is fed by the live omp model
8
+ * registry, and its mutations start from an empty list — so a demo model can
9
+ * never leak into a real installation (see `routes/models/root.ts`).
10
+ */
3
11
  export const INITIAL_MODELS_CATALOG: AIModelOption[] = [
4
12
  // FAVORITES (As seen in image.png)
5
13
  {
@@ -277,7 +277,7 @@ export function getSampleDialogueSession(): {
277
277
  id: '65e71033',
278
278
  role: 'ai',
279
279
  content:
280
- 'Repo summary from `RepoSummarizer`:\n\n**OMPChamber** — developer web-view console and diagnostic chamber for AI **Oh-My-Pi** autonomous agent.\n\n**Stack:** Remix v2 + React 19 + Vite, Tailwind CSS v4, TypeScript (strict, ES2022). Runs under npm or Bun.\n\n**Structure:**\n- `app/routes/` — Remix flat-routes, large REST-style `api/` domain tree\n- `app/components/` — common, layout, workspace, mobile, settings\n- `app/hooks/` — chat, terminal, git-tree, agent streaming\n- `app/lib/omp/` — bridges to oh-my-pi runtime (RPC managers, session readers)\n- `app/types/`, `app/data/` (mock presets)\n\n**Config:** `MOCK` mode toggles seeded demo data vs real data. SQLite persistence (`app/db.server.ts`).\n\n**Deployment-relevant:** PWA support, `@google/genai` assistant, xterm/KaTeX/Shiki rendering.',
280
+ 'Repo summary from `RepoSummarizer`:\n\n**OMPChamber** — developer web-view console and diagnostic chamber for AI **Oh-My-Pi** autonomous agent.\n\n**Stack:** Remix v2 + React 19 + Vite, Tailwind CSS v4, TypeScript (strict, ES2022). Runs under npm or Bun.\n\n**Structure:**\n- `app/routes/` — Remix flat-routes, large REST-style `api/` domain tree\n- `app/components/` — common, layout, workspace, mobile, settings\n- `app/hooks/` — chat, terminal, git-tree, agent streaming\n- `app/lib/omp/` — bridges to oh-my-pi runtime (RPC managers, session readers)\n- `app/types/`, `app/data/` (mock presets)\n\n**Config:** `MOCK` mode toggles seeded demo data vs real data. SQLite persistence (`app/db.server.ts`).\n\n**Deployment-relevant:** PWA support, xterm/KaTeX/Shiki rendering.',
281
281
  date: 'Sep 9, 04:44 AM',
282
282
  },
283
283
  ],
@@ -88,7 +88,7 @@ export const otherTools: ToolCallData[] = [
88
88
  target: 'app/mock.server.ts',
89
89
  output: `1: export function isMockMode(): boolean {
90
90
  2: const envVal = (Bun.env.MOCK || "").trim().toLowerCase();
91
- 3: return envVal !== "false" && envVal !== "0";
91
+ 3: return envVal === "true" || envVal === "1" || envVal === "on" || envVal === "yes";
92
92
  4: }`,
93
93
  status: 'success',
94
94
  duration: '14ms',
@@ -311,7 +311,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
311
311
  }
312
312
  }
313
313
 
314
- // Mock / chamber-created session: existing Gemini/simulated SSE path.
314
+ // Mock / chamber-created session: existing simulated SSE path.
315
315
  // Cancel any previous stream
316
316
  optimisticUserIdRef.current = null;
317
317
  if (abortControllerRef.current) {
@@ -1,6 +1,26 @@
1
1
  import { Elysia } from 'elysia';
2
2
  import { apiRoutes } from '@/server/routes';
3
3
  import { ssrRoutes } from '@/server/plugins/ssr';
4
+ import { getDatabasePath } from '@/server/db.server';
5
+ import { fetchOmpRegistrySnapshot } from '@/server/lib/models/provider-registry.server';
6
+ import { ompStartupError, ompStartupLogLines } from '@/server/lib/omp/core/startup';
7
+ import { isMockMode } from '@/server/mock.server';
8
+
9
+ // Refuse to run without a resolvable omp binary — before the listener opens and
10
+ // before the first request can reach a route that shells out to it. The database
11
+ // is opened lazily by getDb(), so this exits without touching it.
12
+ const startupError = ompStartupError();
13
+ if (startupError) {
14
+ console.error(startupError);
15
+ process.exit(1);
16
+ }
17
+
18
+ // Which omp the chamber drives, which ~/.omp tree it reads and which SQLite file
19
+ // it writes — the paths every session/agent diagnostic traces back to. The data
20
+ // mode comes first because it changes what every later line means.
21
+ console.log(`[ompchamber] mock mode: ${isMockMode() ? 'true (demo presets)' : 'false (real omp data)'}`);
22
+ for (const line of ompStartupLogLines()) console.log(`[ompchamber] ${line}`);
23
+ console.log(`[ompchamber] db: ${await getDatabasePath()}`);
4
24
 
5
25
  const port = Number(Bun.env.PORT) || 3000;
6
26
  const host = Bun.env.HOST || 'localhost';
@@ -9,8 +29,41 @@ const app = new Elysia().use(apiRoutes).use(ssrRoutes);
9
29
 
10
30
  await app.listen({ port, hostname: host });
11
31
 
32
+ // The listener is already accepting requests here, so the probe only postpones
33
+ // this last banner line, never startup. Printing it after the probe keeps the
34
+ // one line that matters — the URL — at the bottom of the log.
35
+ await logDetectedRegistry();
36
+
12
37
  console.log(`[ompchamber] listening on http://${host}:${port}`);
13
38
 
39
+ /**
40
+ * What omp reports it can actually run. A live RPC round-trip: a cold utility
41
+ * process takes seconds, which is why it runs after the listener is up. Side
42
+ * benefit — it warms the shared utility process the model picker drives, so the
43
+ * first /api/models call does not pay the spawn.
44
+ */
45
+ async function logDetectedRegistry(): Promise<void> {
46
+ if (isMockMode()) {
47
+ console.log('[ompchamber] providers: n/a (MOCK mode)');
48
+ console.log('[ompchamber] models: n/a (MOCK mode)');
49
+ return;
50
+ }
51
+ try {
52
+ const { providers, models } = await fetchOmpRegistrySnapshot();
53
+ // Count providers that actually serve a model — the distinct `provider`
54
+ // values in the model list, i.e. the same set GET /api/models groups by.
55
+ // omp's login catalog lists every sign-in offer it supports (75 here, 73 of
56
+ // them without credentials); counting those would report a provider set the
57
+ // chamber cannot use. The catalog size is kept as context.
58
+ const servingProviders = new Set(models.map((model) => model.provider)).size;
59
+ console.log(`[ompchamber] providers: ${servingProviders} detected (${providers.length} known to omp)`);
60
+ console.log(`[ompchamber] models: ${models.length} detected`);
61
+ } catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ console.log(`[ompchamber] providers/models: detection failed — ${message}`);
64
+ }
65
+ }
66
+
14
67
  for (const signal of ['SIGTERM', 'SIGINT'] as const) {
15
68
  process.once(signal, () => {
16
69
  void app.stop();
@@ -23,12 +23,54 @@ export const PROVIDERS_SETTINGS_KEY = 'omp_providers_config';
23
23
 
24
24
  const RPC_CACHE_TTL_MS = 60_000;
25
25
 
26
- interface LoginProvider {
26
+ /** Minimal shape of a get_login_providers entry, as the chamber reads it. */
27
+ export interface DetectedLoginProvider {
27
28
  id: string;
28
29
  name: string;
29
30
  authenticated: boolean;
30
31
  }
31
32
 
33
+ function isDetectedLoginProvider(provider: unknown): provider is DetectedLoginProvider {
34
+ return (
35
+ typeof provider === 'object' && provider !== null
36
+ && typeof (provider as { id?: unknown }).id === 'string'
37
+ && typeof (provider as { name?: unknown }).name === 'string'
38
+ && typeof (provider as { authenticated?: unknown }).authenticated === 'boolean'
39
+ );
40
+ }
41
+
42
+ function isOmpModel(model: unknown): model is OmpModel {
43
+ return (
44
+ typeof model === 'object' && model !== null
45
+ && typeof (model as OmpModel).id === 'string'
46
+ && typeof (model as OmpModel).provider === 'string'
47
+ );
48
+ }
49
+
50
+ export interface OmpRegistrySnapshot {
51
+ /** Login providers omp reports (authenticated or not). */
52
+ providers: DetectedLoginProvider[];
53
+ /** Every model omp currently serves. */
54
+ models: OmpModel[];
55
+ }
56
+
57
+ /**
58
+ * Raw omp registry probe — the two commands that define "detected" for the
59
+ * chamber: the login providers omp knows and the models they serve. Parsed here
60
+ * once so the provider settings page and the startup banner count the same set.
61
+ * Throws when the utility RPC is unreachable; each caller degrades its own way.
62
+ */
63
+ export async function fetchOmpRegistrySnapshot(): Promise<OmpRegistrySnapshot> {
64
+ const [loginResponse, modelsResponse] = await Promise.all([
65
+ runUtilityCommand<{ providers?: unknown }>({ type: 'get_login_providers' }, 30_000),
66
+ runUtilityCommand<{ models?: unknown }>({ type: 'get_available_models' }, 60_000),
67
+ ]);
68
+ return {
69
+ providers: Array.isArray(loginResponse.providers) ? loginResponse.providers.filter(isDetectedLoginProvider) : [],
70
+ models: Array.isArray(modelsResponse.models) ? modelsResponse.models.filter(isOmpModel) : [],
71
+ };
72
+ }
73
+
32
74
  /**
33
75
  * Provider registry straight from the omp agent: authenticated login providers
34
76
  * (omp auth) plus their registered models (get_available_models). This is the
@@ -38,25 +80,7 @@ async function loadRpcProviderItems(): Promise<ProviderItem[]> {
38
80
  const cached = globalThis.__ompChamberProvidersRpcCache;
39
81
  if (cached && cached.expiresAt > Date.now()) return cached.data;
40
82
  try {
41
- const [loginResponse, modelsResponse] = await Promise.all([
42
- runUtilityCommand<{ providers?: unknown }>({ type: 'get_login_providers' }, 30_000),
43
- runUtilityCommand<{ models?: unknown }>({ type: 'get_available_models' }, 60_000),
44
- ]);
45
- const loginProviders = Array.isArray(loginResponse.providers)
46
- ? loginResponse.providers.filter((provider): provider is LoginProvider => (
47
- typeof provider === 'object' && provider !== null
48
- && typeof (provider as { id?: unknown }).id === 'string'
49
- && typeof (provider as { name?: unknown }).name === 'string'
50
- && typeof (provider as { authenticated?: unknown }).authenticated === 'boolean'
51
- ))
52
- : [];
53
- const available = Array.isArray(modelsResponse.models)
54
- ? modelsResponse.models.filter((model): model is OmpModel => (
55
- typeof model === 'object' && model !== null
56
- && typeof (model as OmpModel).id === 'string'
57
- && typeof (model as OmpModel).provider === 'string'
58
- ))
59
- : [];
83
+ const { providers: loginProviders, models: available } = await fetchOmpRegistrySnapshot();
60
84
  const disabled = await readDisabledProviders().catch(() => new Set<string>());
61
85
 
62
86
  const items = loginProviders.map((provider) => {
@@ -20,6 +20,7 @@ let cachedBin: string | null = null;
20
20
  let binMissAt = 0;
21
21
 
22
22
  const BIN_NAME = process.platform === 'win32' ? 'omp.exe' : 'omp';
23
+
23
24
  // Only successes are cached for the process lifetime. omp may be installed (or
24
25
  // PATH repaired) while the server runs; a permanently cached "not found" would
25
26
  // keep the UI reporting a missing binary until restart.
@@ -32,7 +33,7 @@ export function invalidateOmpCliCache(): void {
32
33
  }
33
34
 
34
35
  function probeOmpBin(): string | null {
35
- const override = Bun.env.OMP_WEB_OMP_BIN;
36
+ const override = Bun.env.OMPCHAMBER_OMP_BIN;
36
37
  if (override) return existsSync(override) ? override : null;
37
38
 
38
39
  const onPath = Bun.which(BIN_NAME);
@@ -49,7 +50,7 @@ function probeOmpBin(): string | null {
49
50
  return Bun.which(BIN_NAME, { PATH: fallbackPath });
50
51
  }
51
52
 
52
- /** Resolve the omp binary: OMP_WEB_OMP_BIN override, then PATH lookup. Returns
53
+ /** Resolve the omp binary: OMPCHAMBER_OMP_BIN override, then PATH lookup. Returns
53
54
  * null when omp is not installed. A hit is cached for the process lifetime; a
54
55
  * miss is re-probed after MISS_TTL_MS. */
55
56
  export function resolveOmpBin(): string | null {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Startup gate: the chamber must not run without a resolvable `omp` binary.
8
+ *
9
+ * Every live capability — RPC agent sessions, config get/set, session state,
10
+ * model lists, updates — shells out to that binary (see ./cli.ts), so a missing
11
+ * install is not a degraded mode to browse into: the UI would open onto an
12
+ * empty shell whose every action fails. Both entry points call this before
13
+ * binding a port (src/server/index.ts) or spawning a detached server
14
+ * (src/cli/lib/commands/serve.js), turning the failure into one actionable line
15
+ * instead of a health check that never passes.
16
+ *
17
+ * `MOCK=true` is exempt by definition — demo mode exists to run on presets
18
+ * without a real omp install.
19
+ */
20
+
21
+ import { resolveOmpBin } from '@/server/lib/omp/core/cli';
22
+ import { getAgentDir, getConfigRoot } from '@/server/lib/omp/core/paths';
23
+ import { isMockMode } from '@/server/mock.server';
24
+
25
+ /** Null when the app may start; otherwise the message to print before exiting. */
26
+ export function ompStartupError(): string | null {
27
+ if (isMockMode()) return null;
28
+ if (resolveOmpBin()) return null;
29
+ return [
30
+ 'omp binary not found — OMPChamber cannot start.',
31
+ 'Agent sessions, omp config, session state and updates all run through the `omp` CLI,',
32
+ 'so the binary must be resolvable before the server starts.',
33
+ 'Install AI Oh-My-Pi and put `omp` on your PATH, or set OMPCHAMBER_OMP_BIN=/path/to/omp.',
34
+ 'To run on demo data without an omp install, start with MOCK=true.',
35
+ ].join('\n');
36
+ }
37
+
38
+ interface OmpStartupPaths {
39
+ /** Resolved `omp` executable, or null when unresolved (mock mode). */
40
+ bin: string | null;
41
+ /** `~/.omp` — PI_CONFIG_DIR rename applied. */
42
+ configDir: string;
43
+ /** `~/.omp/agent` — PI_CODING_AGENT_DIR override applied. */
44
+ agentDir: string;
45
+ }
46
+
47
+ /** Startup banner lines. The caller owns the prefix.
48
+ *
49
+ * These are the locations the running process actually uses: the executable it
50
+ * spawns and the agent tree it reads. Printing them makes a misdirected install
51
+ * (two omps on PATH, a stray PI_CODING_AGENT_DIR) visible in the log instead of
52
+ * inferred from empty session lists. */
53
+ export function ompStartupLogLines(): string[] {
54
+ const paths: OmpStartupPaths = {
55
+ bin: resolveOmpBin(),
56
+ configDir: getConfigRoot(),
57
+ agentDir: getAgentDir(),
58
+ };
59
+ return [
60
+ `omp binary: ${paths.bin ?? 'not found (MOCK mode — presets only)'}`,
61
+ `omp config dir: ${paths.configDir}`,
62
+ `omp agent dir: ${paths.agentDir}`,
63
+ ];
64
+ }
@@ -46,7 +46,7 @@ export class RpcProcess {
46
46
  const resolveBin = options.dependencies?.resolveOmpBin ?? resolveOmpBin;
47
47
  const bin = resolveBin();
48
48
  if (!bin) {
49
- throw new Error('omp binary not found. Install oh-my-pi or set OMP_WEB_OMP_BIN.');
49
+ throw new Error('omp binary not found. Install oh-my-pi or set OMPCHAMBER_OMP_BIN.');
50
50
  }
51
51
  this.cwd = options.cwd;
52
52
  if (options.onFrame) this.frameListeners.add(options.onFrame);
@@ -5,29 +5,23 @@
5
5
 
6
6
  /**
7
7
  * Utility to determine whether the application is running in MOCK mode.
8
- *
8
+ *
9
9
  * - When MOCK=true (or MOCK=1): all data uses mock / simulated presets (demo chat traces, mock token analytics, mock settings presets).
10
10
  * - When MOCK=false (or MOCK=0): all data uses real data (real SQLite persistence, real token calculations, real workspace state).
11
+ * - Unset (or any unrecognized value) defaults to REAL data — the chamber is a
12
+ * diagnostic console for a live omp install, so demo presets must be opted
13
+ * into explicitly rather than inherited by omission.
11
14
  */
12
15
  export function isMockMode(): boolean {
13
16
  const envVal = (Bun.env.MOCK || '').trim().toLowerCase();
14
-
15
- if (envVal === 'false' || envVal === '0' || envVal === 'off' || envVal === 'no') {
16
- return false;
17
- }
18
-
19
- if (envVal === 'true' || envVal === '1' || envVal === 'on' || envVal === 'yes') {
20
- return true;
21
- }
22
17
 
23
- // Default to true for sandbox / preview demo mode if not explicitly set to false
24
- return true;
18
+ return envVal === 'true' || envVal === '1' || envVal === 'on' || envVal === 'yes';
25
19
  }
26
20
 
27
21
  export function getMockModeInfo(): { isMock: boolean; rawEnv: string } {
28
22
  const isMock = isMockMode();
29
23
  return {
30
24
  isMock,
31
- rawEnv: Bun.env.MOCK || (isMock ? 'true (default)' : 'false'),
25
+ rawEnv: Bun.env.MOCK || (isMock ? 'true' : 'false (default)'),
32
26
  };
33
27
  }
@@ -1,6 +1,6 @@
1
1
  import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-compat';
2
2
  import { isMockMode } from '@/server/mock.server';
3
- import { handleGeminiStreaming, handleSimulatedStreaming } from '@/shared/lib/chat/stream-service';
3
+ import { handleSimulatedStreaming } from '@/shared/lib/chat/stream-service';
4
4
  import { createSseStream } from '@/server/lib/sse';
5
5
 
6
6
  export async function loader({ request }: LoaderFunctionArgs) {
@@ -31,7 +31,7 @@ async function handleStreamingRequest(request: Request) {
31
31
  }
32
32
 
33
33
  // Real mode routes through the omp agent RPC bridge (POST /api/agent/:id +
34
- // SSE /api/agent/:id/events). This Gemini/simulated stream is the MOCK-only
34
+ // SSE /api/agent/:id/events). This simulated stream is the MOCK-only
35
35
  // path — refuse it in real mode so the two never conflict.
36
36
  if (!isMockMode()) {
37
37
  return new Response(JSON.stringify({ error: 'Streaming is handled by the omp agent bridge in real mode' }), {
@@ -68,34 +68,17 @@ async function handleStreamingRequest(request: Request) {
68
68
  model: modelName,
69
69
  });
70
70
 
71
- const apiKey = Bun.env.GEMINI_API_KEY;
72
- const mock = isMockMode();
73
-
74
- // If real Gemini API is available and not forced mock mode
75
- if (apiKey && !mock) {
76
- await handleGeminiStreaming({
77
- apiKey,
78
- prompt,
79
- modelName,
80
- sessionId,
81
- messageId,
82
- timeStr,
83
- sendEvent,
84
- isAborted: () => isAborted,
85
- });
86
- } else {
87
- // Fallback / simulated intelligent streaming for OMPChamber
88
- await handleSimulatedStreaming({
89
- prompt,
90
- modelName,
91
- workspaceName,
92
- sessionId,
93
- messageId,
94
- timeStr,
95
- sendEvent,
96
- isAborted: () => isAborted,
97
- });
98
- }
71
+ // Simulated intelligent streaming — the MOCK-only path for OMPChamber.
72
+ await handleSimulatedStreaming({
73
+ prompt,
74
+ modelName,
75
+ workspaceName,
76
+ sessionId,
77
+ messageId,
78
+ timeStr,
79
+ sendEvent,
80
+ isAborted: () => isAborted,
81
+ });
99
82
  } catch (err: any) {
100
83
  if (!isAborted) {
101
84
  sendEvent('error', { error: err?.message || 'Streaming execution error' });
@@ -274,7 +274,11 @@ export async function action({ request }: ActionFunctionArgs) {
274
274
  const body = await request.json();
275
275
 
276
276
  const catalog = await readSettingsJson<unknown>(db, MODELS_CATALOG_KEY, null);
277
- let models: AIModelOption[] = Array.isArray(catalog) ? catalog : INITIAL_MODELS_CATALOG;
277
+ // The shipped demo catalog is MOCK-only: a real install starts from an empty
278
+ // list, so a demo model can never be persisted into its stored catalog.
279
+ let models: AIModelOption[] = Array.isArray(catalog)
280
+ ? catalog
281
+ : (isMockMode() ? INITIAL_MODELS_CATALOG : []);
278
282
 
279
283
  const { actionType, modelId, provider, thinkingLevel, model, models: newModels } = body;
280
284
 
@@ -1,5 +1,4 @@
1
1
  import { getDb } from '@/server/db.server';
2
- import { GoogleGenAI } from '@google/genai';
3
2
  import type { ChatMessageData, ToolCallData } from '@/shared/types';
4
3
 
5
4
  // Helper to delay execution for realistic stream simulation
@@ -35,81 +34,6 @@ async function persistAiMessage(sessionId: string, aiMessage: ChatMessageData) {
35
34
  }
36
35
  }
37
36
 
38
- // Handler for real Gemini API streaming
39
- export async function handleGeminiStreaming(options: {
40
- apiKey: string;
41
- prompt: string;
42
- modelName: string;
43
- sessionId: string;
44
- messageId: string;
45
- timeStr: string;
46
- sendEvent: StreamSendEvent;
47
- isAborted: () => boolean;
48
- }) {
49
- const { apiKey, prompt, sessionId, messageId, timeStr, sendEvent, isAborted } = options;
50
- const ai = new GoogleGenAI({
51
- apiKey,
52
- httpOptions: {
53
- headers: {
54
- 'User-Agent': 'aistudio-build',
55
- },
56
- },
57
- });
58
-
59
- sendEvent('thinking_start', { title: 'Deep Reasoning' });
60
- const thoughtText = `Deconstructing prompt: "${prompt}".\nAnalyzing project context, evaluating edge dependencies, and generating validated response.`;
61
-
62
- // Stream thinking chunk
63
- sendEvent('thinking_chunk', { delta: thoughtText });
64
- sendEvent('thinking_end', {
65
- thought: thoughtText,
66
- summary: 'Analyze context and stream response',
67
- duration: '0.8s',
68
- });
69
-
70
- sendEvent('content_start', {});
71
-
72
- const responseStream = await ai.models.generateContentStream({
73
- model: 'gemini-3.8-flash',
74
- contents: prompt || 'Hello',
75
- config: {
76
- systemInstruction: 'You are OMPChamber AI Assistant, integrated with Oh-My-Pi, remisJS, and Bun runtime environments. Provide concise, clean, markdown-formatted responses.',
77
- },
78
- });
79
-
80
- let fullContent = '';
81
- for await (const chunk of responseStream) {
82
- if (isAborted()) break;
83
- const textChunk = chunk.text || '';
84
- if (textChunk) {
85
- fullContent += textChunk;
86
- sendEvent('content_chunk', { delta: textChunk });
87
- }
88
- }
89
-
90
- sendEvent('content_end', {});
91
-
92
- const summary = 'Generation completed successfully via Gemini API.';
93
- sendEvent('summary', { summary });
94
-
95
- const finalMessage: ChatMessageData = {
96
- id: messageId,
97
- role: 'ai',
98
- date: `Today, ${timeStr}`,
99
- timestamp: timeStr,
100
- thinking: {
101
- duration: '0.8s',
102
- thought: thoughtText,
103
- summary: 'Analyze context and stream response',
104
- },
105
- content: fullContent,
106
- summary,
107
- };
108
-
109
- sendEvent('done', { message: finalMessage });
110
- await persistAiMessage(sessionId, finalMessage);
111
- }
112
-
113
37
  // Handler for domain-aware simulated streaming with tool executions & chunked tokens
114
38
  export async function handleSimulatedStreaming(options: {
115
39
  prompt: string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Path formatting for the Files panel's copy actions.
3
+ *
4
+ * `/api/fs/dir` emits every entry as a path relative to the listing's base
5
+ * directory — the workspace root, or the selected nested repo — and reports
6
+ * that base back as `root`. Copy Path rebuilds the absolute path from those
7
+ * two halves; a relative path on its own is not resolvable outside the panel
8
+ * (the client-side `rootPath` may be `~`-relative, rejected by the server, or
9
+ * absent entirely in mock mode).
10
+ */
11
+ export function toAbsolutePath(baseDir: string | null | undefined, relPath: string): string {
12
+ // `/`- and `./`-prefixed raws are stripped; a dot-file name (`.env`) is not a prefix.
13
+ const rel = relPath.replace(/^(?:\.?[/\\])+/, '').replace(/[/\\]+$/, '');
14
+ const base = (baseDir ?? '').replace(/[/\\]+$/, '');
15
+ if (!base) return rel;
16
+ if (!rel) return base;
17
+ // Join with the base's own separator: `C:\proj` + `src` → `C:\proj\src`.
18
+ return `${base}${base.includes('\\') && !base.includes('/') ? '\\' : '/'}${rel}`;
19
+ }