mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
fix(mobile): guard Agent Mode off the mobile load path (#2577)
* fix(mobile): shim `process` so the bundle survives module-eval on mobile Follow-up to #2576 (preview issue #125). After the EventEmitter fix, mobile now crashes at load with "ReferenceError: Can't find variable: process". Cause: Agent Mode is desktop-only but its modules are statically imported, so they're evaluated on every platform. Several read the Node global `process` at module scope (e.g. `InstallCommandRow.tsx`'s `const DEFAULT_LABEL = process.platform === "win32" ? ...`, the codex/claude/opencode descriptors), and bundled Node deps read `process.env` at init. Mobile's WebView has no `process`, so evaluating any of them throws during import and kills the plugin. Fix: prepend a minimal `process` shim in the esbuild banner — `var process = globalThis.process || { env:{}, platform:"", ... }`. Desktop (Electron) has a real `globalThis.process` and uses it unchanged; mobile gets the stub, so module evaluation no longer throws. The desktop-only code paths that actually use `process` never run on mobile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(test-vault): copy-deploy into iCloud-synced vaults for mobile testing `npm run test:vault` symlinks main.js/styles.css by default, but iCloud syncs file contents, not link targets, so a symlinked build never reaches the phone. Detect iCloud vaults (path under "/Mobile Documents/") and copy real files instead, so deploying to an iCloud vault makes the build testable on Obsidian mobile. Mirrors the existing WSL copy-mode rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mobile): keep agent mode off load path * test(mobile): add load smoke check * fix(mobile): share agent chat mode constant --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c62211628c
commit
775d832ee5
13 changed files with 598 additions and 55 deletions
2
.github/workflows/node.js.yml
vendored
2
.github/workflows/node.js.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run build --if-present
|
||||
- run: npm run test:mobile-load
|
||||
- run: npm test
|
||||
- name: Run Integration tests
|
||||
# Only run integration tests for trusted events (push) or when secrets are available
|
||||
|
|
|
|||
|
|
@ -41,6 +41,28 @@ if (typeof import_meta === 'undefined') {
|
|||
url: typeof __filename !== 'undefined' ? 'file://' + __filename : 'file:///obsidian-plugin'
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal \`process\` shim so the bundle survives MODULE EVALUATION on Obsidian
|
||||
// mobile, whose WebView has no Node \`process\` global. Desktop-only code (Agent
|
||||
// Mode) is statically imported and therefore evaluated on every platform; many
|
||||
// of those modules — and bundled Node deps — read \`process.platform\`/\`.env\` at
|
||||
// module scope, which throws "Can't find variable: process" on mobile and kills
|
||||
// the whole plugin at load. On desktop (Electron) \`globalThis.process\` exists
|
||||
// and is used as-is, so this only stubs mobile, where those code paths never run.
|
||||
var process = globalThis.process || {
|
||||
env: {},
|
||||
platform: '',
|
||||
arch: '',
|
||||
argv: [],
|
||||
version: '',
|
||||
versions: {},
|
||||
cwd: function () { return '/'; },
|
||||
nextTick: function (cb) { Promise.resolve().then(cb); },
|
||||
on: function () {},
|
||||
off: function () {},
|
||||
once: function () {},
|
||||
emit: function () { return false; },
|
||||
};
|
||||
`;
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"format:check": "prettier --check 'src/**/*.{js,ts,tsx,md}'",
|
||||
"version": "node version-bump.mjs",
|
||||
"test": "jest --testPathIgnorePatterns=src/integration_tests/",
|
||||
"test:mobile-load": "npm run build && node scripts/mobile-load-smoke.cjs",
|
||||
"test:integration": "jest src/integration_tests/",
|
||||
"prepare": "husky",
|
||||
"prompt:debug": "node scripts/printPromptDebug.js",
|
||||
|
|
|
|||
370
scripts/mobile-load-smoke.cjs
Normal file
370
scripts/mobile-load-smoke.cjs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const failures = [];
|
||||
|
||||
const loadCriticalFiles = [
|
||||
"src/main.ts",
|
||||
"src/commands/index.ts",
|
||||
"src/settings/SettingsPage.tsx",
|
||||
"src/settings/v2/SettingsMainV2.tsx",
|
||||
"src/settings/v2/components/AdvancedSettings.tsx",
|
||||
"src/components/chat-components/plugins/SlashCommandPlugin.tsx",
|
||||
"src/components/chat-components/plugins/slashMenuItems.ts",
|
||||
];
|
||||
|
||||
const nodeModuleIds = new Set([
|
||||
"async_hooks",
|
||||
"buffer",
|
||||
"child_process",
|
||||
"crypto",
|
||||
"electron",
|
||||
"events",
|
||||
"fs",
|
||||
"fs/promises",
|
||||
"module",
|
||||
"os",
|
||||
"path",
|
||||
"process",
|
||||
"readline",
|
||||
"stream",
|
||||
"url",
|
||||
"util",
|
||||
"node:async_hooks",
|
||||
"node:buffer",
|
||||
"node:child_process",
|
||||
"node:crypto",
|
||||
"node:events",
|
||||
"node:fs",
|
||||
"node:fs/promises",
|
||||
"node:module",
|
||||
"node:os",
|
||||
"node:path",
|
||||
"node:process",
|
||||
"node:readline",
|
||||
"node:stream",
|
||||
"node:url",
|
||||
"node:util",
|
||||
]);
|
||||
|
||||
function readRepoFile(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
if (!(error instanceof Error)) return String(error);
|
||||
|
||||
const stackLines = (error.stack ?? "")
|
||||
.split("\n")
|
||||
.filter((line) => !line.includes("main.js:1:"))
|
||||
.slice(0, 6);
|
||||
|
||||
return [`${error.name}: ${error.message}`, ...stackLines.slice(1)].join("\n");
|
||||
}
|
||||
|
||||
function isTypeOnlyImport(importStatement) {
|
||||
if (/^import\s+type\b/.test(importStatement.trim())) return true;
|
||||
const named = importStatement.match(/import\s*\{([^}]*)\}\s*from/);
|
||||
if (!named) return false;
|
||||
const specifiers = named[1]
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
return specifiers.length > 0 && specifiers.every((part) => part.startsWith("type "));
|
||||
}
|
||||
|
||||
function checkAgentModeImportBoundaries() {
|
||||
const staticAgentModeImport =
|
||||
/import\s+(?:type\s+)?[^;]+?\s+from\s+["']@\/agentMode(?:\/[^"']*)?["']\s*;?/g;
|
||||
const dynamicAgentModeImport = /import\s*\(\s*["']@\/agentMode(?:\/[^"']*)?["']\s*\)/g;
|
||||
|
||||
for (const relativePath of loadCriticalFiles) {
|
||||
const source = readRepoFile(relativePath);
|
||||
|
||||
for (const match of source.matchAll(staticAgentModeImport)) {
|
||||
const statement = match[0];
|
||||
if (!isTypeOnlyImport(statement)) {
|
||||
fail(`${relativePath}: value import from Agent Mode is on the mobile load path.`);
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicImports = Array.from(source.matchAll(dynamicAgentModeImport));
|
||||
if (dynamicImports.length > 0) {
|
||||
if (!source.includes("Platform.isDesktopApp")) {
|
||||
fail(`${relativePath}: dynamic Agent Mode import must be gated by Platform.isDesktopApp.`);
|
||||
}
|
||||
if (source.includes("Platform.isMobile")) {
|
||||
fail(
|
||||
`${relativePath}: Agent Mode gates should use Platform.isDesktopApp, not Platform.isMobile.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createCallableStub(name) {
|
||||
function Stub() {}
|
||||
Object.defineProperty(Stub, "name", { value: name.replace(/[^A-Za-z0-9_$]/g, "_") || "Stub" });
|
||||
return new Proxy(Stub, {
|
||||
apply() {
|
||||
return undefined;
|
||||
},
|
||||
construct() {
|
||||
return {};
|
||||
},
|
||||
get(target, prop) {
|
||||
if (prop === "prototype") return target.prototype;
|
||||
if (prop === Symbol.toStringTag) return name;
|
||||
return createCallableStub(`${name}.${String(prop)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createPoisonModule(id) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === Symbol.toStringTag) return id;
|
||||
throw new Error(
|
||||
`mobile-load-smoke: desktop-only module '${id}' was accessed at '${String(prop)}'.`
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function isObsidianBrowserExternal(id) {
|
||||
return id.startsWith("@codemirror/") || id.startsWith("@lezer/");
|
||||
}
|
||||
|
||||
function createObsidianStub() {
|
||||
class Component {
|
||||
registerEvent() {}
|
||||
registerDomEvent() {}
|
||||
registerInterval() {}
|
||||
load() {}
|
||||
unload() {}
|
||||
}
|
||||
|
||||
class Plugin extends Component {
|
||||
constructor(app, manifest) {
|
||||
super();
|
||||
this.app = app;
|
||||
this.manifest = manifest ?? { id: "copilot", version: "mobile-load-smoke" };
|
||||
}
|
||||
|
||||
addCommand() {}
|
||||
addRibbonIcon() {
|
||||
return { addClass() {}, removeClass() {}, setAttribute() {} };
|
||||
}
|
||||
addSettingTab() {}
|
||||
loadData() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
saveData() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
registerView() {}
|
||||
registerEditorExtension() {}
|
||||
}
|
||||
|
||||
class PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.containerEl = { empty() {}, addClass() {}, createDiv: () => ({}) };
|
||||
}
|
||||
}
|
||||
|
||||
class ItemView extends Component {
|
||||
constructor(leaf) {
|
||||
super();
|
||||
this.leaf = leaf;
|
||||
this.containerEl = {};
|
||||
this.contentEl = {};
|
||||
}
|
||||
}
|
||||
|
||||
class Modal extends Component {
|
||||
constructor(app) {
|
||||
super();
|
||||
this.app = app;
|
||||
}
|
||||
open() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
class Notice {
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
class TFile {}
|
||||
class TFolder {}
|
||||
class FileSystemAdapter {}
|
||||
class MarkdownView {}
|
||||
|
||||
const obsidian = {
|
||||
App: class App {},
|
||||
Component,
|
||||
FileSystemAdapter,
|
||||
ItemView,
|
||||
MarkdownView,
|
||||
Modal,
|
||||
Notice,
|
||||
Platform: Object.freeze({
|
||||
isDesktop: false,
|
||||
isDesktopApp: false,
|
||||
isMobile: true,
|
||||
isMobileApp: true,
|
||||
isMacOS: false,
|
||||
isPhone: true,
|
||||
isTablet: false,
|
||||
isWin: false,
|
||||
}),
|
||||
Plugin,
|
||||
PluginSettingTab,
|
||||
TFile,
|
||||
TFolder,
|
||||
WorkspaceLeaf: class WorkspaceLeaf {},
|
||||
MarkdownRenderer: { render: async () => {} },
|
||||
addIcon() {},
|
||||
debounce(fn) {
|
||||
return fn;
|
||||
},
|
||||
moment: () => ({ format: () => "" }),
|
||||
normalizePath(value) {
|
||||
return String(value).replace(/\\/g, "/");
|
||||
},
|
||||
parseYaml() {
|
||||
return {};
|
||||
},
|
||||
requestUrl: async () => ({ json: {}, text: "", status: 200 }),
|
||||
stringifyYaml() {
|
||||
return "";
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy(obsidian, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return target[prop];
|
||||
return createCallableStub(`obsidian.${String(prop)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class SmokeEvent {
|
||||
constructor(type, init = {}) {
|
||||
this.type = type;
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
class SmokeCustomEvent extends SmokeEvent {
|
||||
constructor(type, init = {}) {
|
||||
super(type, init);
|
||||
this.detail = init.detail;
|
||||
}
|
||||
}
|
||||
|
||||
class SmokeEventTarget {
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function runBundleEvaluationSmoke() {
|
||||
const bundlePath = path.join(repoRoot, "main.js");
|
||||
if (!fs.existsSync(bundlePath)) {
|
||||
fail("main.js is missing. Run npm run build before the mobile-load smoke test.");
|
||||
return;
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(bundlePath, "utf8");
|
||||
const module = { exports: {} };
|
||||
const context = {
|
||||
AbortController,
|
||||
clearInterval,
|
||||
clearTimeout,
|
||||
console,
|
||||
crypto: {
|
||||
getRandomValues(array) {
|
||||
array.fill(7);
|
||||
return array;
|
||||
},
|
||||
randomUUID() {
|
||||
return "00000000-0000-4000-8000-000000000000";
|
||||
},
|
||||
},
|
||||
CustomEvent: SmokeCustomEvent,
|
||||
Event: SmokeEvent,
|
||||
EventTarget: SmokeEventTarget,
|
||||
fetch: async () => ({ ok: true, json: async () => ({}), text: async () => "", body: null }),
|
||||
Blob,
|
||||
module,
|
||||
exports: module.exports,
|
||||
FormData,
|
||||
Headers,
|
||||
navigator: { userAgent: "ObsidianMobileSmoke/1.0" },
|
||||
Promise,
|
||||
queueMicrotask,
|
||||
ReadableStream,
|
||||
Request,
|
||||
Response,
|
||||
require(id) {
|
||||
if (id === "obsidian") return createObsidianStub();
|
||||
if (isObsidianBrowserExternal(id)) return createCallableStub(id);
|
||||
if (nodeModuleIds.has(id)) return createPoisonModule(id);
|
||||
throw new Error(`mobile-load-smoke: unexpected external require '${id}'.`);
|
||||
},
|
||||
setInterval,
|
||||
setTimeout,
|
||||
TextDecoder,
|
||||
TextEncoder,
|
||||
TransformStream,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
WritableStream,
|
||||
};
|
||||
context.globalThis = context;
|
||||
context.self = context;
|
||||
context.window = context;
|
||||
|
||||
try {
|
||||
vm.runInNewContext(source, context, {
|
||||
filename: "main.js",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(`main.js failed mobile bundle evaluation: ${formatError(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginExport = module.exports.default ?? module.exports;
|
||||
if (typeof pluginExport !== "function") {
|
||||
fail("main.js did not export the plugin class.");
|
||||
}
|
||||
}
|
||||
|
||||
checkAgentModeImportBoundaries();
|
||||
runBundleEvaluationSmoke();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Mobile load smoke test failed:");
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Mobile load smoke test passed.");
|
||||
|
|
@ -59,6 +59,12 @@ DEPLOY_MODE="link"
|
|||
if [[ "$(uname -r 2>/dev/null)" == *[Mm]icrosoft* ]] && [[ "$VAULT_REAL" == /mnt/* ]]; then
|
||||
DEPLOY_MODE="copy"
|
||||
fi
|
||||
# iCloud-synced vaults (Obsidian on iOS/iPadOS) can't use symlinks: iCloud
|
||||
# uploads file contents, not link targets, so a symlinked main.js never reaches
|
||||
# the phone. Copy real files so the mobile device receives the build.
|
||||
if [[ "$VAULT_REAL" == *"/Mobile Documents/"* ]]; then
|
||||
DEPLOY_MODE="copy"
|
||||
fi
|
||||
|
||||
echo "==> Installing dependencies"
|
||||
npm install --prefer-offline --no-audit --no-fund
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
createDefaultPermissionPrompter,
|
||||
} from "./ui/permissionPrompter";
|
||||
|
||||
export { AGENT_CHAT_MODE } from "./session/AgentChatPersistenceManager";
|
||||
export { AGENT_CHAT_MODE } from "@/constants";
|
||||
export { AgentModeChat } from "./ui/AgentModeChat";
|
||||
export { default as CopilotAgentView } from "./ui/CopilotAgentView";
|
||||
export {
|
||||
|
|
@ -61,7 +61,7 @@ export { PlanPreviewView, PLAN_PREVIEW_VIEW_TYPE } from "./ui/PlanPreviewView";
|
|||
export type { PlanPreviewViewState } from "./ui/PlanPreviewView";
|
||||
export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry";
|
||||
export { frameSink as acpFrameSink, setFrameSinkVaultBasePath } from "./session/debugSink";
|
||||
export { SkillManager, SkillsSettings, useManagedSkills } from "./skills";
|
||||
export { getManagedSkills, SkillManager, SkillsSettings, useManagedSkills } from "./skills";
|
||||
export type { Skill } from "./skills";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AI_SENDER, USER_SENDER } from "@/constants";
|
||||
import { AGENT_CHAT_MODE, AI_SENDER, USER_SENDER } from "@/constants";
|
||||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { FormattedDateTime } from "@/types/message";
|
||||
|
|
@ -23,7 +23,6 @@ import type { AgentChatMessage, BackendId } from "./types";
|
|||
|
||||
const SAFE_FILENAME_BYTE_LIMIT = 100;
|
||||
export const AGENT_FILENAME_PREFIX = "agent__";
|
||||
export const AGENT_CHAT_MODE = "agent";
|
||||
|
||||
/**
|
||||
* Result of `loadFile` — restores display-only Agent Mode messages plus
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ import { shouldUseMiyo } from "@/miyo/miyoUtils";
|
|||
import { getAllQAMarkdownContent } from "@/search/searchUtils";
|
||||
import { NoteSelectedTextContext, WebSelectedTextContext } from "@/types/message";
|
||||
import { ensureFolderExists, isSourceModeOn } from "@/utils";
|
||||
import { Editor, MarkdownView, Notice, TFile } from "obsidian";
|
||||
import { isAgentModeEnabled } from "@/agentMode";
|
||||
import { Editor, MarkdownView, Notice, Platform, TFile } from "obsidian";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { COMMAND_IDS, COMMAND_ICONS, COMMAND_NAMES, CommandId } from "@/constants";
|
||||
import { setSelectedTextContexts } from "@/aiParams";
|
||||
|
|
@ -129,7 +128,7 @@ export function registerCommands(plugin: CopilotPlugin) {
|
|||
|
||||
// Agent Mode is always on, but requires subprocess support — register the
|
||||
// agent commands on desktop only.
|
||||
if (isAgentModeEnabled()) {
|
||||
if (Platform.isDesktopApp) {
|
||||
addCommand(plugin, COMMAND_IDS.OPEN_AGENT_CHAT_WINDOW, () => {
|
||||
void plugin.activateAgentView();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { $getSelection, $isRangeSelection, TextNode } from "lexical";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { Platform } from "obsidian";
|
||||
|
||||
import { listBackendDescriptors, useIsAgentModeEnabled, useManagedSkills } from "@/agentMode";
|
||||
import type { BackendId } from "@/agentMode";
|
||||
import { useCustomCommands } from "@/commands/state";
|
||||
import { CustomCommandManager } from "@/commands/customCommandManager";
|
||||
import { sortSlashCommands } from "@/commands/customCommandUtils";
|
||||
import { logError } from "@/logger";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import { TypeaheadMenuPortal } from "@/components/chat-components/TypeaheadMenuPortal";
|
||||
import { TypeaheadOption } from "@/components/chat-components/TypeaheadMenuContent";
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
useTypeaheadPlugin,
|
||||
TypeaheadState,
|
||||
} from "@/components/chat-components/hooks/useTypeaheadPlugin";
|
||||
import type { BackendId, Skill } from "@/agentMode";
|
||||
|
||||
import { composeSlashMenuItems, type SlashMenuItem } from "./slashMenuItems";
|
||||
|
||||
|
|
@ -22,6 +23,63 @@ interface SlashCommandOption extends TypeaheadOption {
|
|||
item: SlashMenuItem;
|
||||
}
|
||||
|
||||
interface AgentSlashData {
|
||||
enabled: boolean;
|
||||
skills: Skill[];
|
||||
backendIds: ReadonlySet<BackendId> | null;
|
||||
}
|
||||
|
||||
const EMPTY_SKILLS = Object.freeze([]) as unknown as Skill[];
|
||||
const EMPTY_AGENT_SLASH_DATA: AgentSlashData = Object.freeze({
|
||||
enabled: false,
|
||||
skills: EMPTY_SKILLS,
|
||||
backendIds: null,
|
||||
});
|
||||
|
||||
function useAgentSlashData(): AgentSlashData {
|
||||
const [data, setData] = useState<AgentSlashData>(EMPTY_AGENT_SLASH_DATA);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isDesktopApp) return;
|
||||
|
||||
let cancelled = false;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
async function loadAgentSlashData(): Promise<void> {
|
||||
const { getManagedSkills, SkillManager, listBackendDescriptors } =
|
||||
await import("@/agentMode");
|
||||
|
||||
const update = () => {
|
||||
if (cancelled) return;
|
||||
setData({
|
||||
enabled: true,
|
||||
skills: getManagedSkills(),
|
||||
backendIds: new Set(listBackendDescriptors().map((descriptor) => descriptor.id)),
|
||||
});
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
if (SkillManager.hasInstance()) {
|
||||
unsubscribe = SkillManager.getInstance().subscribeToSkillSetChange(update);
|
||||
}
|
||||
}
|
||||
|
||||
void loadAgentSlashData().catch((error) => {
|
||||
if (!cancelled) {
|
||||
logError("Failed to load Agent Mode slash commands.", error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active backend id for slash-menu filtering. Returns `null`
|
||||
* when Agent Mode is disabled, or when `activeBackend` is unset/unknown —
|
||||
|
|
@ -29,14 +87,16 @@ interface SlashCommandOption extends TypeaheadOption {
|
|||
* skill) rather than silently filtering everything out, which is the
|
||||
* behaviour a typoed or stale setting would otherwise produce.
|
||||
*/
|
||||
function useActiveSlashBackend(): BackendId | null {
|
||||
function useActiveSlashBackend(
|
||||
agentModeEnabled: boolean,
|
||||
backendIds: ReadonlySet<BackendId> | null
|
||||
): BackendId | null {
|
||||
const settings = useSettingsValue();
|
||||
const agentModeEnabled = useIsAgentModeEnabled();
|
||||
if (!agentModeEnabled) return null;
|
||||
const activeBackend = settings.agentMode?.activeBackend;
|
||||
if (typeof activeBackend !== "string" || activeBackend.length === 0) return null;
|
||||
const known = listBackendDescriptors().some((d) => d.id === activeBackend);
|
||||
return known ? activeBackend : null;
|
||||
if (!backendIds?.has(activeBackend)) return null;
|
||||
return activeBackend;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,8 +115,8 @@ function useActiveSlashBackend(): BackendId | null {
|
|||
export function SlashCommandPlugin(): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const commands = useCustomCommands();
|
||||
const skills = useManagedSkills();
|
||||
const activeBackend = useActiveSlashBackend();
|
||||
const agentSlashData = useAgentSlashData();
|
||||
const activeBackend = useActiveSlashBackend(agentSlashData.enabled, agentSlashData.backendIds);
|
||||
const [currentQuery, setCurrentQuery] = useState("");
|
||||
|
||||
// Recency / strategy sort happens before composition so the slash plugin
|
||||
|
|
@ -64,7 +124,7 @@ export function SlashCommandPlugin(): JSX.Element {
|
|||
const sortedCommands = useMemo(() => sortSlashCommands(commands), [commands]);
|
||||
|
||||
const allOptions = useMemo<SlashCommandOption[]>(() => {
|
||||
const items = composeSlashMenuItems(skills, sortedCommands, activeBackend);
|
||||
const items = composeSlashMenuItems(agentSlashData.skills, sortedCommands, activeBackend);
|
||||
return items.map((item) => ({
|
||||
key: item.key,
|
||||
title: item.name,
|
||||
|
|
@ -74,7 +134,7 @@ export function SlashCommandPlugin(): JSX.Element {
|
|||
content: item.body,
|
||||
item,
|
||||
}));
|
||||
}, [skills, sortedCommands, activeBackend]);
|
||||
}, [agentSlashData.skills, sortedCommands, activeBackend]);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!currentQuery) return allOptions;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const BREVILABS_API_BASE_URL = "https://api.brevilabs.com/v1";
|
|||
export const BREVILABS_MODELS_BASE_URL = "https://models.brevilabs.com/v1";
|
||||
export const CHAT_VIEWTYPE = "copilot-chat-view";
|
||||
export const CHAT_AGENT_VIEWTYPE = "copilot-agent-chat-view";
|
||||
export const AGENT_CHAT_MODE = "agent";
|
||||
export const RELEVANT_NOTES_VIEWTYPE = "copilot-relevant-notes-view";
|
||||
|
||||
// Custom Obsidian icon for Agent Mode surfaces (view tab, ribbon, commands).
|
||||
|
|
|
|||
80
src/main.ts
80
src/main.ts
|
|
@ -1,15 +1,4 @@
|
|||
import {
|
||||
AGENT_CHAT_MODE,
|
||||
CopilotAgentView,
|
||||
createAgentSessionManager,
|
||||
isAgentModeEnabled,
|
||||
PlanPreviewView,
|
||||
PLAN_PREVIEW_VIEW_TYPE,
|
||||
setFrameSinkVaultBasePath,
|
||||
SkillManager,
|
||||
type AgentSessionManager,
|
||||
} from "@/agentMode";
|
||||
import { wireAgentModelDiscovery } from "@/agentMode/agentModelDiscovery";
|
||||
import type { AgentSessionManager } from "@/agentMode";
|
||||
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
|
||||
import ProjectManager from "@/LLMProviders/projectManager";
|
||||
import {
|
||||
|
|
@ -33,6 +22,7 @@ import { SystemPromptRegister } from "@/system-prompts/systemPromptRegister";
|
|||
import { ProjectRegister } from "@/projects/projectRegister";
|
||||
import {
|
||||
ABORT_REASON,
|
||||
AGENT_CHAT_MODE,
|
||||
CHAT_AGENT_VIEWTYPE,
|
||||
CHAT_VIEWTYPE,
|
||||
COPILOT_AGENT_ICON_ID,
|
||||
|
|
@ -134,6 +124,9 @@ export default class CopilotPlugin extends Plugin {
|
|||
settingsUnsubscriber?: () => void;
|
||||
chatUIState: ChatManagerChatUIState;
|
||||
agentSessionManager?: AgentSessionManager;
|
||||
private CopilotAgentView?: typeof import("@/agentMode").CopilotAgentView;
|
||||
private PlanPreviewView?: typeof import("@/agentMode").PlanPreviewView;
|
||||
private planPreviewViewType?: typeof import("@/agentMode").PLAN_PREVIEW_VIEW_TYPE;
|
||||
private agentModelDiscoveryUnsubscriber?: () => void;
|
||||
modelManagement!: ModelManagementApi;
|
||||
private ribbonIconEl?: HTMLElement;
|
||||
|
|
@ -248,7 +241,19 @@ export default class CopilotPlugin extends Plugin {
|
|||
this.projectManager = ProjectManager.getInstance(this.app, this);
|
||||
|
||||
// Initialize Agent Mode coordinator (desktop only — ACP needs subprocess support).
|
||||
if (!Platform.isMobile) {
|
||||
if (Platform.isDesktopApp) {
|
||||
const {
|
||||
CopilotAgentView,
|
||||
PlanPreviewView,
|
||||
PLAN_PREVIEW_VIEW_TYPE,
|
||||
createAgentSessionManager,
|
||||
setFrameSinkVaultBasePath,
|
||||
} = await import("@/agentMode");
|
||||
const { wireAgentModelDiscovery } = await import("@/agentMode/agentModelDiscovery");
|
||||
this.CopilotAgentView = CopilotAgentView;
|
||||
this.PlanPreviewView = PlanPreviewView;
|
||||
this.planPreviewViewType = PLAN_PREVIEW_VIEW_TYPE;
|
||||
|
||||
// Seed the frame-log sink with the vault base path (desktop FileSystemAdapter only).
|
||||
const adapter = this.app.vault.adapter;
|
||||
setFrameSinkVaultBasePath(
|
||||
|
|
@ -314,14 +319,21 @@ export default class CopilotPlugin extends Plugin {
|
|||
RELEVANT_NOTES_VIEWTYPE,
|
||||
(leaf: WorkspaceLeaf) => new RelevantNotesView(leaf, this)
|
||||
);
|
||||
if (!Platform.isMobile) {
|
||||
if (
|
||||
Platform.isDesktopApp &&
|
||||
this.CopilotAgentView &&
|
||||
this.PlanPreviewView &&
|
||||
this.planPreviewViewType
|
||||
) {
|
||||
const AgentView = this.CopilotAgentView;
|
||||
const PreviewView = this.PlanPreviewView;
|
||||
this.safeRegisterView(
|
||||
CHAT_AGENT_VIEWTYPE,
|
||||
(leaf: WorkspaceLeaf) => new CopilotAgentView(leaf, this)
|
||||
(leaf: WorkspaceLeaf) => new AgentView(leaf, this)
|
||||
);
|
||||
this.safeRegisterView(
|
||||
PLAN_PREVIEW_VIEW_TYPE,
|
||||
(leaf: WorkspaceLeaf) => new PlanPreviewView(leaf)
|
||||
this.planPreviewViewType,
|
||||
(leaf: WorkspaceLeaf) => new PreviewView(leaf)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -459,8 +471,11 @@ export default class CopilotPlugin extends Plugin {
|
|||
this.settingsUnsubscriber?.();
|
||||
|
||||
// Tear down skills vault watchers + debounce timers.
|
||||
if (SkillManager.hasInstance()) {
|
||||
SkillManager.getInstance().dispose();
|
||||
if (Platform.isDesktopApp) {
|
||||
const { SkillManager } = await import("@/agentMode");
|
||||
if (SkillManager.hasInstance()) {
|
||||
SkillManager.getInstance().dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup selection handler
|
||||
|
|
@ -556,8 +571,8 @@ export default class CopilotPlugin extends Plugin {
|
|||
.getLeavesOfType(viewType)
|
||||
.map((leaf) => leaf.view)
|
||||
.find(
|
||||
(v): v is CopilotView | CopilotAgentView =>
|
||||
v instanceof CopilotView || v instanceof CopilotAgentView
|
||||
(v): v is CopilotView | InstanceType<NonNullable<typeof this.CopilotAgentView>> =>
|
||||
v instanceof CopilotView || this.isCopilotAgentView(v)
|
||||
);
|
||||
|
||||
if (view) {
|
||||
|
|
@ -864,7 +879,7 @@ export default class CopilotPlugin extends Plugin {
|
|||
// mount-timing guess. Also covers the already-open-and-active case, where
|
||||
// revealLeaf fires no active-leaf-change to drive focus.
|
||||
const view = leaf?.view;
|
||||
if (view instanceof CopilotAgentView) {
|
||||
if (this.isCopilotAgentView(view)) {
|
||||
view.eventTarget.queueVisible();
|
||||
}
|
||||
return leaf;
|
||||
|
|
@ -899,11 +914,13 @@ export default class CopilotPlugin extends Plugin {
|
|||
if (!leaf) return;
|
||||
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
const view = leaf.view as CopilotView | CopilotAgentView;
|
||||
const view = leaf.view;
|
||||
// The bus latches the text if the view's React tree hasn't mounted its
|
||||
// listener yet, so a freshly-opened view drains it on mount — delivery no
|
||||
// longer depends on guessing how long mounting takes.
|
||||
view.eventTarget.queueInsertText(text);
|
||||
if (view instanceof CopilotView || this.isCopilotAgentView(view)) {
|
||||
view.eventTarget.queueInsertText(text);
|
||||
}
|
||||
}
|
||||
|
||||
async newAgentChat(): Promise<void> {
|
||||
|
|
@ -934,7 +951,7 @@ export default class CopilotPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private requireAgentView(): AgentSessionManager | null {
|
||||
if (Platform.isMobile) {
|
||||
if (!Platform.isDesktopApp) {
|
||||
new Notice("Agent Chat is not available on mobile.");
|
||||
return null;
|
||||
}
|
||||
|
|
@ -946,7 +963,14 @@ export default class CopilotPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private canUseAgentView(): boolean {
|
||||
return !!this.agentSessionManager && isAgentModeEnabled();
|
||||
return !!this.agentSessionManager && Platform.isDesktopApp;
|
||||
}
|
||||
|
||||
private isCopilotAgentView(
|
||||
view: unknown
|
||||
): view is InstanceType<NonNullable<typeof this.CopilotAgentView>> {
|
||||
const AgentView = this.CopilotAgentView;
|
||||
return !!AgentView && view instanceof AgentView;
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
|
|
@ -1147,7 +1171,9 @@ export default class CopilotPlugin extends Plugin {
|
|||
await manager.loadSessionFromHistory(file);
|
||||
void this.touchChatHistoryLastAccessedAt(file);
|
||||
|
||||
(leaf.view as CopilotAgentView | undefined)?.updateView();
|
||||
if (this.isCopilotAgentView(leaf.view)) {
|
||||
leaf.view.updateView();
|
||||
}
|
||||
}
|
||||
|
||||
async openChatSourceFile(fileId: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ import CopilotPlugin from "@/main";
|
|||
import { ByokPanel, ModelManagementProvider } from "@/modelManagement";
|
||||
import { resetSettings } from "@/settings/model";
|
||||
import { CommandSettings } from "@/settings/v2/components/CommandSettings";
|
||||
import { SkillsSettings } from "@/agentMode";
|
||||
import { Bot, Cog, Command, Cpu, Database, Sparkle, Sparkles, Wrench } from "lucide-react";
|
||||
import { Platform } from "obsidian";
|
||||
import React from "react";
|
||||
import { AdvancedSettings } from "./components/AdvancedSettings";
|
||||
import { AgentSettings } from "./components/AgentSettings";
|
||||
import { BasicSettings } from "./components/BasicSettings";
|
||||
import { CopilotPlusSettings } from "./components/CopilotPlusSettings";
|
||||
import { QASettings } from "./components/QASettings";
|
||||
|
|
@ -20,6 +19,37 @@ import { QASettings } from "./components/QASettings";
|
|||
const TAB_IDS = ["basic", "byok", "agent", "QA", "command", "skills", "plus", "advanced"] as const;
|
||||
type TabId = (typeof TAB_IDS)[number];
|
||||
|
||||
const LazyAgentSettings = React.lazy(() =>
|
||||
import("./components/AgentSettings").then((module) => ({ default: module.AgentSettings }))
|
||||
);
|
||||
const LazySkillsSettings = React.lazy(() =>
|
||||
import("@/agentMode").then((module) => ({ default: module.SkillsSettings }))
|
||||
);
|
||||
|
||||
const DesktopOnlySettingsPanel: React.FC = () => (
|
||||
<section className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-4 tw-text-sm tw-text-muted">
|
||||
Agent settings are available on desktop.
|
||||
</section>
|
||||
);
|
||||
|
||||
const AgentSettingsPanel: React.FC = () => {
|
||||
if (!Platform.isDesktopApp) return <DesktopOnlySettingsPanel />;
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<LazyAgentSettings />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const SkillsSettingsPanel: React.FC = () => {
|
||||
if (!Platform.isDesktopApp) return <DesktopOnlySettingsPanel />;
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<LazySkillsSettings />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
// tab icons
|
||||
const icons: Record<TabId, JSX.Element> = {
|
||||
basic: <Cog className="tw-size-5" />,
|
||||
|
|
@ -36,10 +66,10 @@ const icons: Record<TabId, JSX.Element> = {
|
|||
const components: Record<TabId, React.FC> = {
|
||||
basic: () => <BasicSettings />,
|
||||
byok: () => <ByokPanel />,
|
||||
agent: () => <AgentSettings />,
|
||||
agent: AgentSettingsPanel,
|
||||
QA: () => <QASettings />,
|
||||
command: () => <CommandSettings />,
|
||||
skills: () => <SkillsSettings />,
|
||||
skills: SkillsSettingsPanel,
|
||||
plus: () => <CopilotPlusSettings />,
|
||||
advanced: () => <AdvancedSettings />,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,15 +22,16 @@ import {
|
|||
updateSetting,
|
||||
useSettingsValue,
|
||||
} from "@/settings/model";
|
||||
import { acpFrameSink } from "@/agentMode";
|
||||
import { ArrowUpRight, Info, Plus, ShieldCheck, Trash2, Unlock } from "lucide-react";
|
||||
import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
||||
import { MigrateConfirmModal } from "@/components/modals/MigrateConfirmModal";
|
||||
import { type App, Notice } from "obsidian";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { type App, Notice, Platform } from "obsidian";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { getPromptFilePath, SystemPromptAddModal } from "@/system-prompts";
|
||||
import { useSystemPrompts } from "@/system-prompts/state";
|
||||
|
||||
const DESKTOP_UNAVAILABLE_FRAME_LOG_PATH = "(Agent Mode frame logs are desktop-only)";
|
||||
|
||||
/**
|
||||
* Returns a `saveData` callback bound to the loaded Copilot plugin instance.
|
||||
*
|
||||
|
|
@ -58,6 +59,22 @@ export const AdvancedSettings: React.FC = () => {
|
|||
const prompts = useSystemPrompts();
|
||||
const [forgetting, setForgetting] = useState(false);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [frameLogPath, setFrameLogPath] = useState(DESKTOP_UNAVAILABLE_FRAME_LOG_PATH);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isDesktopApp) return;
|
||||
|
||||
let cancelled = false;
|
||||
void import("@/agentMode").then(({ acpFrameSink }) => {
|
||||
if (!cancelled) {
|
||||
setFrameLogPath(acpFrameSink.getPath());
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const keychainAvailable = KeychainService.getInstance().isAvailable();
|
||||
const keychainOnly = isKeychainOnly(settings);
|
||||
|
|
@ -455,7 +472,7 @@ export const AdvancedSettings: React.FC = () => {
|
|||
<SettingItem
|
||||
type="switch"
|
||||
title="Log Full Agent Mode Frames"
|
||||
description={`Writes diagnostic Agent Mode frames as NDJSON outside your vault at ${acpFrameSink.getPath()}. Frames include prompts, tool inputs/outputs, and attachments; oversized frames are summarized to avoid runaway logs. Sensitive content lands on disk in plaintext. Leave off unless actively debugging.`}
|
||||
description={`Writes diagnostic Agent Mode frames as NDJSON outside your vault at ${frameLogPath}. Frames include prompts, tool inputs/outputs, and attachments; oversized frames are summarized to avoid runaway logs. Sensitive content lands on disk in plaintext. Leave off unless actively debugging.`}
|
||||
checked={settings.agentMode.debugFullFrames}
|
||||
onCheckedChange={(checked) => {
|
||||
setSettings((cur) => ({
|
||||
|
|
@ -467,15 +484,21 @@ export const AdvancedSettings: React.FC = () => {
|
|||
<SettingItem
|
||||
type="custom"
|
||||
title="Agent Mode Frame Log"
|
||||
description={`Open or clear the Agent Mode frame log (${acpFrameSink.getPath()}).`}
|
||||
description={`Open or clear the Agent Mode frame log (${frameLogPath}).`}
|
||||
>
|
||||
<div className="tw-flex tw-gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!Platform.isDesktopApp) {
|
||||
new Notice("Agent Mode frame logs are available on desktop only.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { acpFrameSink } = await import("@/agentMode");
|
||||
await acpFrameSink.open();
|
||||
setFrameLogPath(acpFrameSink.getPath());
|
||||
} catch {
|
||||
new Notice("Failed to open Agent Mode frame log.");
|
||||
}
|
||||
|
|
@ -487,8 +510,14 @@ export const AdvancedSettings: React.FC = () => {
|
|||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!Platform.isDesktopApp) {
|
||||
new Notice("Agent Mode frame logs are available on desktop only.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { acpFrameSink } = await import("@/agentMode");
|
||||
await acpFrameSink.clear();
|
||||
setFrameLogPath(acpFrameSink.getPath());
|
||||
new Notice("Agent Mode frame log cleared.");
|
||||
} catch {
|
||||
new Notice("Failed to clear Agent Mode frame log.");
|
||||
|
|
|
|||
Loading…
Reference in a new issue