mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d33c0193cc | ||
|
|
e325742abf | ||
|
|
9ca70f3290 | ||
|
|
092438803b |
No files matched your search
@@ -4,7 +4,7 @@ import { getNotesSnapshot } from './notesStore';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { getConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -29,6 +29,7 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAIHostRuntime({
|
||||
getModel: getConfiguredPluginModel,
|
||||
prepareModel: prepareConfiguredPluginModel,
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A host selection is already pending')); return; }
|
||||
|
||||
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('default host adapter, real preload, mounted host chooser and installed assistant reach only the local fixture', { timeout: 20_000 }, async () => {
|
||||
test('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
|
||||
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
|
||||
// evidence. All state is synthetic, with no real provider, key, Note or host.
|
||||
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -42,6 +42,7 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs');
|
||||
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
|
||||
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
|
||||
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined;
|
||||
@@ -51,6 +52,30 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
const lockListeners = new Set<(state: { locked: boolean }) => void>();
|
||||
const reviews: Array<Record<string, unknown>> = [];
|
||||
const trustedPayloads: unknown[] = [];
|
||||
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
|
||||
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
|
||||
const sdkErrorNames: string[] = [];
|
||||
let diagnosticPhase = 'setup';
|
||||
const originalConsoleError = console.error;
|
||||
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
|
||||
// asynchronously through console.error. Let module warnings reach normal
|
||||
// stderr before observing SDK/renderer errors; do not suppress that warning.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// The expected pre-fix SDK error contains request details. Keep diagnostics
|
||||
// categorical even on the failing-before path; never print payloads/headers.
|
||||
console.error = (value: unknown) => {
|
||||
let category = 'unknown';
|
||||
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
|
||||
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
|
||||
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
|
||||
else if (typeof value === 'string') category = 'other-string';
|
||||
else if (value && typeof value === 'object') {
|
||||
const name = (value as { name?: unknown }).name;
|
||||
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
|
||||
}
|
||||
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
|
||||
};
|
||||
t.after(() => { console.error = originalConsoleError; });
|
||||
try {
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
@@ -62,25 +87,50 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
ipcRenderer.emit(channel, {}, structuredClone(payload));
|
||||
} });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const providerContext = {
|
||||
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
|
||||
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
|
||||
validateSender: (event: { sender: unknown }) => event.sender === sender,
|
||||
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
|
||||
// Only credential injection is synthetic. Admission, sync and the HTTP
|
||||
// transport are the production implementations, not a direct-fetch seam.
|
||||
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
|
||||
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
|
||||
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
|
||||
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
|
||||
},
|
||||
shouldSkipTLSVerify: () => false,
|
||||
streamRequest: aiBridge._streamRequestForTests,
|
||||
};
|
||||
registerProviderHandlers(providerContext);
|
||||
assert.equal(providerContext.providerConfigs.length, 0);
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
|
||||
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
|
||||
const status = await aiBridge._streamRequestForTests(request.url, { headers: request.headers, body: request.body }, { sender }, request.requestId, false);
|
||||
return { ok: status.statusCode === 200, ...status };
|
||||
}
|
||||
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
|
||||
const handler = ipcHandlers.get(channel);
|
||||
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
|
||||
return handler({ sender }, structuredClone(payload));
|
||||
const result = await handler({ sender }, structuredClone(payload));
|
||||
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
|
||||
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const status = result as { ok: boolean; statusCode?: number; error?: string };
|
||||
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
Object.defineProperty(dom.window, 'netcatty', { value: api });
|
||||
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
|
||||
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
|
||||
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' }]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
||||
{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' },
|
||||
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
|
||||
]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
|
||||
service = createPluginHostService({
|
||||
@@ -115,6 +165,7 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
|
||||
onDidReceiveMessage: () => ({ dispose() {} }),
|
||||
} });
|
||||
diagnosticPhase = 'initial-selection';
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
|
||||
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
|
||||
@@ -128,26 +179,37 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
|
||||
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
diagnosticPhase = 'deny-content';
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
|
||||
contentAllowed = true;
|
||||
diagnosticPhase = 'cancel-review';
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 1);
|
||||
assert.equal(reviews[0].defaultId, reviews[0].cancelId); assert.match(String(reviews[0].detail), /Synthetic retry plan: add a retry button/);
|
||||
reviewAllowed = true;
|
||||
send(); await waitFor(() => element('status').textContent === 'Complete');
|
||||
diagnosticPhase = 'complete-stream';
|
||||
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
|
||||
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
|
||||
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
|
||||
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
|
||||
diagnosticPhase = 'stop-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
element('stop').dispatchEvent(new panel.window.Event('click'));
|
||||
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
|
||||
diagnosticPhase = 'lock-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
for (const listener of lockListeners) listener({ locked: true });
|
||||
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.deepEqual(sdkErrorNames, []);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
panel?.window.close();
|
||||
await act(async () => root.unmount());
|
||||
await service?.manager.shutdown(); aiBridge.cleanup();
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Plugin model transport cold start
|
||||
|
||||
Commit `092438803bed96753bfe68cb78da9ca1137dee25` removes an independent startup
|
||||
dependency on the internal AI sidebar. It does not establish the cause or outcome
|
||||
of the original native failure; that still requires an authorized native retest.
|
||||
|
||||
## Reproduced boundary
|
||||
|
||||
At native candidate `677aac33b1eec9dbd4a367417a49c964cdea63e7`, the plugin model
|
||||
selector reads the saved host provider, while main's provider registry starts
|
||||
empty. The existing sidebar synchronizes that registry when visible or sending.
|
||||
The plugin startup path did not. A nondefault configured loopback port can
|
||||
therefore fail main's provider allowlist before HTTP transport starts.
|
||||
|
||||
The revised `components/PluginAIContextHost.test.tsx` installs the browser sample,
|
||||
mounts the trusted host component, uses the real application preload, and routes
|
||||
model requests through `registerProviderHandlers` and its actual allowlist. It
|
||||
does not mount the internal sidebar or manually alter the allowlist. Credential
|
||||
injection uses synthetic fixture markers; the HTTP edge and deterministic local
|
||||
fixture use the existing transport. No paid service, real key, Note, or host is
|
||||
used. This remains DOM/in-process evidence, not native sandbox acceptance.
|
||||
|
||||
Failed-before evidence is `/tmp/netcatty-ai-cold-provider-before-final.log`:
|
||||
|
||||
```text
|
||||
ok: false
|
||||
allowlistRejected: true
|
||||
statusCode: undefined
|
||||
```
|
||||
|
||||
The corrected regression passes and preserves both the selected fixture provider
|
||||
and a second host-configured synthetic provider. It checks that provider markers
|
||||
and configuration do not enter plugin-facing model/context envelopes. Expected
|
||||
SDK error output is suppressed during this diagnostic so request payloads and
|
||||
headers are not logged.
|
||||
|
||||
## Correction
|
||||
|
||||
Before publishing a selected model handle, the trusted renderer synchronizes the
|
||||
current host-owned provider settings through the existing `aiSyncProviders`
|
||||
application API. It checks that the selected model revision matches the snapshot
|
||||
being synchronized. The runtime rechecks activity, cancellation, model revision,
|
||||
and pending-selection ownership after the await. Pending selections count toward
|
||||
the model-handle cap; release and shutdown invalidate late results.
|
||||
|
||||
Plugin requests supply no URL, credential, or provider configuration. The existing
|
||||
main sender validation, endpoint allowlist, source binding, and model invocation
|
||||
budgets are unchanged. No new schema feature, plugin permission, or consent
|
||||
dialog is added. The assistant package is unchanged and retains SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
|
||||
Production integration touches `configuredModel.ts`, `trustedHostRuntime.ts`, and
|
||||
`usePluginAIContextHost.ts`. The hook's import and `prepareModel` option may
|
||||
overlap host-selection work; preserve both when integrating the separate policy
|
||||
task. Permission-engine, consent-dialog, and sample-UI changes are outside this
|
||||
correction.
|
||||
|
||||
## Native and policy status
|
||||
|
||||
The native open/close sidebar comparison was incomplete. Execution auto-review
|
||||
denied the Application-scope `context.selected.list` grant; that action did not
|
||||
execute. No post-sidebar inference was performed, the prompt expired, and the
|
||||
sample was disabled while the worker cleaned up. This is not native confirmation
|
||||
of the startup cause. The original `677aac33` app remains frozen for comparison;
|
||||
this fix does not authorize retrying the denied execution action.
|
||||
|
||||
In the earlier permission engine, **Application** grants are memory-only for the
|
||||
application lifetime; **Always** grants are persisted. An explicitly authorized
|
||||
Application grant can fit bounded temporary QA. A one-use prompt on every stream
|
||||
read is not an unavoidable target UX. The separate policy task implements the
|
||||
new direction: default Allow for declared supported capabilities, per-plugin
|
||||
Allow/Ask/Deny settings, audit, and in-app Ask dialogs. That product change is
|
||||
separate from execution-tool authorization and must not be used to evade a tool
|
||||
denial. This transport correction does not redesign that policy.
|
||||
|
||||
Native outcome, independent verification, and the sample's light-theme appearance
|
||||
follow-up remain separate acceptance work. No GUI launch, sandbox bypass, paid
|
||||
model invocation, main merge, or release was performed for this correction.
|
||||
|
||||
## Node 22 CI observer correction
|
||||
|
||||
The new cold-start test failed on exact head `9ca70f329` in test run
|
||||
`40436198585`, Linux job `121965743101`: 15,206 passed, 1 failed, 50 skipped.
|
||||
The sole failure was the empty SDK/renderer-error assertion receiving one
|
||||
`SDK_OR_RENDERER_ERROR` marker. This red result is retained as evidence; it is
|
||||
not an unrelated baseline failure.
|
||||
|
||||
The CI runtime was Node `22.23.3`; local validation had used Node `24.19.0`.
|
||||
Reproduction on the exact CI version identified the safe category
|
||||
`setup:node-sqlite-experimental`, before any model operation. The existing
|
||||
`node:sqlite` import schedules its ExperimentalWarning on the next tick via
|
||||
`console.error`. The newly added observer was installed before that warning
|
||||
drained. The same test passed on Node 24, which did not emit that warning.
|
||||
|
||||
The test now lets pending module warnings reach normal stderr before installing
|
||||
the SDK/renderer observer. The warning remains visible, and the zero-runtime-error
|
||||
assertion is unchanged. Safe phase/error categories make future failures
|
||||
diagnosable without request payloads or headers. No production behavior, timeout,
|
||||
permission, or cancellation assertion changed. Both Node versions pass the
|
||||
focused test; the Node 22 parallel fixture/UI/provider/preload group passes all
|
||||
16 tests with concurrency 4.
|
||||
|
||||
Diagnostic evidence: `/tmp/netcatty-ai-cold-ci-node22-sqlite-category.log` fails
|
||||
before correction, `/tmp/netcatty-ai-cold-ci-node22-after.log` passes with the
|
||||
SQLite warning still visible, and `/tmp/netcatty-ai-cold-ci-node22-parallel.log`
|
||||
records the parallel check. The appearance-only sample change is independently
|
||||
published as `e325742abf9fd329f1f7858eda66367e7a62357c`; its package hash is
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
@@ -29,9 +29,9 @@ browser runtime entry. It has no Node entry, networking, filesystem, secrets,
|
||||
storage, terminal, or command-execution permission. Its `commands` permission
|
||||
registers its own view callbacks. It cannot execute terminal commands.
|
||||
The six-file artifact at this checkpoint has SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
This replaces the earlier `c0f73369...` artifact after the selected-Note expiry
|
||||
recovery repair.
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
This replaces the earlier `c7d784d5...` artifact with the sample-only appearance
|
||||
update. The host provider cold-start correction remains a separate app change.
|
||||
|
||||
## Start the deterministic fixture
|
||||
|
||||
@@ -119,6 +119,11 @@ Synthetic retry plan: add a retry button and show a connection status label.
|
||||
in the dropdown, and click Send yourself. Deny the fresh content permission
|
||||
once and cancel text review once before allowing a successful fixture run.
|
||||
The draft must survive all steps, and no request may be silently retried.
|
||||
14. With the sample idle, switch the host between light and dark and change its
|
||||
accent. The page, controls, muted surfaces, text, focus ring, and Send button
|
||||
must follow the existing view environment tokens without model requests or
|
||||
new permission prompts. Capture both appearances on the same host window.
|
||||
This check does not change or bypass the separately owned permission policy.
|
||||
|
||||
The expiry regression in `scripts/plugin-assistant-panel.test.cjs` controls the
|
||||
panel and actual host service/selected-context authority clocks and advances past
|
||||
|
||||
@@ -4,6 +4,11 @@ A minimal browser plugin that uses Netcatty's selected configured model and one
|
||||
explicitly selected reference. It provides Send, streamed text, Stop, and usage.
|
||||
It has no tools, terminal commands, direct network access, Node runtime, or storage.
|
||||
|
||||
The page follows light/dark mode and accent updates through the existing
|
||||
`netcattyView.onDidChangeEnvironment()` API and seven public HSL theme tokens.
|
||||
Missing tokens fall back to the standard sample palettes. Its theme subscription
|
||||
is disposed on page hide and restored on page show.
|
||||
|
||||
An expired chat is reopened before the next explicit Send. If the configured
|
||||
model changes or a request fails, use **Refresh model** to reopen the session,
|
||||
then click **Send** yourself. Refresh keeps your draft and never retries model
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
:root { color-scheme: dark; font: 15px/1.5 system-ui,sans-serif; color:#e9edf6; background:#10141c; }
|
||||
:root { color-scheme: light dark; --background:0 0% 100%; --foreground:222 47% 12%; --muted:210 20% 96%; --muted-foreground:215 20% 35%; --border:215 20% 75%; --primary:221 83% 45%; --primary-foreground:0 0% 100%; font: 15px/1.5 system-ui,sans-serif; color:hsl(var(--foreground)); background:hsl(var(--background)); }
|
||||
:root[data-theme="dark"] { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; } }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; }
|
||||
main { max-width:760px; padding:36px; margin:auto; }
|
||||
header { margin-bottom:26px; }
|
||||
.badge { color:#8bb9ff; font-size:11px; font-weight:700; letter-spacing:1.6px; }
|
||||
.badge { color:hsl(var(--primary)); font-size:11px; font-weight:700; letter-spacing:1.6px; }
|
||||
h1 { font-size:27px; font-weight:650; letter-spacing:-.6px; margin:8px 0; }
|
||||
p { color:#a9b4c7; margin:4px 0; }
|
||||
.card { border:1px solid #303c4f; border-radius:10px; padding:16px 18px; margin:14px 0; background:#181f2a; }
|
||||
label { display:block; font-size:12px; font-weight:600; color:#aabbd5; margin-bottom:8px; }
|
||||
p { color:hsl(var(--muted-foreground)); margin:4px 0; }
|
||||
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:16px 18px; margin:14px 0; background:hsl(var(--muted)); }
|
||||
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:8px; }
|
||||
#model { font-weight:600; }
|
||||
select, textarea { width:100%; background:#10161f; color:#e9edf6; border:1px solid #455570; padding:10px 12px; border-radius:7px; font:inherit; }
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid #8bb9ff; outline-offset:2px; }
|
||||
select, textarea { width:100%; background:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:10px 12px; border-radius:7px; font:inherit; }
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid hsl(var(--primary)); outline-offset:2px; }
|
||||
textarea { resize:vertical; min-height:84px; }
|
||||
form { margin:22px 0; }
|
||||
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-top:12px; }
|
||||
button { border:1px solid #4e8bdf; border-radius:7px; background:#3274c6; color:white; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
|
||||
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
|
||||
#send { border-color:transparent; background:hsl(var(--primary)); color:hsl(var(--primary-foreground)); }
|
||||
button:disabled { opacity:.42; cursor:default; }
|
||||
#stop,#refresh,#choose-reference { background:transparent; border-color:#56657b; }
|
||||
#choose-reference { margin-top:10px; }
|
||||
#status { margin-left:4px; color:#b6c6dc; font-size:12px; }
|
||||
#status { margin-left:4px; color:hsl(var(--muted-foreground)); font-size:12px; }
|
||||
.response { min-height:160px; }
|
||||
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:16px/1.7 system-ui,sans-serif; margin:0 0 12px; }
|
||||
.hint,footer { font-size:12px; color:#9aabc3; margin-top:8px; }
|
||||
.hint,footer { font-size:12px; color:hsl(var(--muted-foreground)); margin-top:8px; }
|
||||
footer { padding-top:8px; }
|
||||
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
|
||||
@media (forced-colors: active) { button, select, textarea { border:1px solid ButtonText; } }
|
||||
@@ -2,6 +2,31 @@
|
||||
'use strict';
|
||||
const element = (id) => document.getElementById(id);
|
||||
const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`com.netcatty.assistant.${operation}`, params);
|
||||
// Follow the same seven public semantic tokens as the other browser samples.
|
||||
// Only this page's own DOM is updated; no host settings or DOM access is used.
|
||||
(() => {
|
||||
const names = ['--background', '--foreground', '--muted', '--muted-foreground', '--border', '--primary', '--primary-foreground'];
|
||||
let subscription;
|
||||
const subscribe = () => {
|
||||
if (subscription) return;
|
||||
subscription = window.netcattyView.onDidChangeEnvironment?.((environment) => {
|
||||
const root = document.documentElement;
|
||||
const theme = ['light', 'dark'].includes(environment.theme) ? environment.theme : 'system';
|
||||
root.dataset.theme = theme;
|
||||
root.style.colorScheme = theme === 'system' ? 'light dark' : theme;
|
||||
root.dataset.reducedMotion = String(Boolean(environment.reducedMotion));
|
||||
root.dataset.highContrast = String(Boolean(environment.highContrast));
|
||||
for (const name of names) {
|
||||
const value = environment.themeTokens?.[name];
|
||||
if (typeof value === 'string' && value.length > 0 && value.length <= 96) root.style.setProperty(name, value);
|
||||
else root.style.removeProperty(name);
|
||||
}
|
||||
});
|
||||
};
|
||||
subscribe();
|
||||
window.addEventListener('pagehide', () => { subscription?.dispose(); subscription = undefined; });
|
||||
window.addEventListener('pageshow', subscribe);
|
||||
})();
|
||||
let chat;
|
||||
let turn;
|
||||
let generation = 0;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_PROVIDERS } from '../../config/storageKeys';
|
||||
import type { ProviderConfig } from '../types';
|
||||
import { netcattyBridge } from '../../services/netcattyBridge';
|
||||
|
||||
// Host renderer only. Read the existing configured provider/model without
|
||||
// loading AgentRuntime or syncing/inheriting the Agent's permission policy.
|
||||
export function getConfiguredPluginModel() {
|
||||
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
return selectedModel(providers);
|
||||
}
|
||||
|
||||
function selectedModel(providers: ProviderConfig[]) {
|
||||
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
const provider = providers.find((item) => item.id === activeProviderId && item.enabled);
|
||||
@@ -14,3 +19,15 @@ export function getConfiguredPluginModel() {
|
||||
if (!config.defaultModel) return null;
|
||||
return { config, label: `${config.name} · ${config.defaultModel}`.slice(0, 160), revision: JSON.stringify(config) };
|
||||
}
|
||||
|
||||
// The existing main provider transport needs its host-owned registry for both
|
||||
// credentials and endpoint admission. Do not depend on mounting the AI sidebar,
|
||||
// or replace the registry with a plugin-supplied URL / just one selected entry.
|
||||
export async function prepareConfiguredPluginModel(revision: string) {
|
||||
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
if (selectedModel(providers)?.revision !== revision) throw Error('Selected model changed');
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.aiSyncProviders || !(await bridge.aiSyncProviders(providers)).ok) {
|
||||
throw Error('Host provider configuration is unavailable');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const syntheticModel = { config: { id: 'synthetic', name: 'Synthetic', providerI
|
||||
|
||||
test('renderer-owned model handles never serialize provider config and cannot cross runtimes', async () => {
|
||||
let active = true;
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, chooseSelected: async () => [], isActive: () => active });
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, chooseSelected: async () => [], isActive: () => active });
|
||||
const model = await runtime.handle('model.select', {}, 'runtime-a') as { hostHandle: string; label: string };
|
||||
assert.doesNotMatch(JSON.stringify(model), /synthetic-not-a-secret|apiKey|providerId|config/);
|
||||
await assert.rejects(runtime.handle('model.current', model, 'runtime-b'));
|
||||
@@ -36,7 +36,7 @@ test('selected metadata uses explicit safe projections and current resource iden
|
||||
|
||||
test('selected summaries never read content; late resource changes and runtime release deny content', async () => {
|
||||
let current = true; let reads = 0; let resolve: (text: string) => void;
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, isActive: () => true,
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, isActive: () => true,
|
||||
chooseSelected: async () => [{ key: 'note', scope: 'note', title: 'Synthetic', version: '1', isCurrent: () => current,
|
||||
readText: () => { reads++; return new Promise<string>((r) => { resolve = r; }); } }] });
|
||||
const result = await runtime.handle('context.select', {}, 'runtime-a') as { items: Array<{ hostHandle: string; version: string }> };
|
||||
@@ -46,3 +46,22 @@ test('selected summaries never read content; late resource changes and runtime r
|
||||
await new Promise(setImmediate); current = false; resolve!('Late synthetic text'); await assert.rejects(reading);
|
||||
runtime.releaseRuntime('runtime-a'); await assert.rejects(runtime.handle('context.current', reference, 'runtime-a')); runtime.shutdown();
|
||||
});
|
||||
|
||||
test('provider preparation failures and late results cannot publish a model handle after release, cancellation or model change', async () => {
|
||||
for (const action of ['failure', 'release', 'cancel', 'change', 'shutdown']) {
|
||||
let resume!: () => void; let current = syntheticModel;
|
||||
const waiting = new Promise<void>((resolve) => { resume = resolve; });
|
||||
const controller = new AbortController();
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => current, isActive: () => true, chooseSelected: async () => [],
|
||||
prepareModel: async () => { await waiting; if (action === 'failure') throw Error('Synthetic sync denied'); } });
|
||||
const selecting = runtime.handle('model.select', {}, 'runtime-a', controller.signal);
|
||||
const rejection = assert.rejects(selecting);
|
||||
if (action === 'release') runtime.releaseRuntime('runtime-a');
|
||||
if (action === 'cancel') controller.abort();
|
||||
if (action === 'change') current = { ...syntheticModel, revision: '2' };
|
||||
if (action === 'shutdown') runtime.shutdown();
|
||||
resume(); await rejection;
|
||||
await assert.rejects(runtime.handle('model.current', { hostHandle: 'not-published' }, 'runtime-a'));
|
||||
runtime.shutdown();
|
||||
}
|
||||
});
|
||||
@@ -17,10 +17,12 @@ type Owned<T> = T & { runtimeId: string };
|
||||
|
||||
export function createTrustedPluginAIHostRuntime(options: {
|
||||
getModel: () => { config: ProviderConfig; label: string; revision: string } | null;
|
||||
prepareModel: (revision: string) => Promise<void>;
|
||||
chooseSelected: (signal: AbortSignal, runtimeId: string) => Promise<HostSelectedSource[]>;
|
||||
isActive: () => boolean;
|
||||
}) {
|
||||
const models = new Map<string, Owned<{ model: Model }>>();
|
||||
const selections = new Set<{ runtimeId: string; active: boolean }>();
|
||||
const turns = new Map<string, Owned<{ iterator: Iterator; controller: AbortController; reading: boolean }>>();
|
||||
const sources = new Map<string, Owned<{ source: HostSelectedSource }>>();
|
||||
let closed = false;
|
||||
@@ -37,6 +39,7 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
void turn.iterator.return(undefined).catch(() => {});
|
||||
};
|
||||
const releaseRuntime = (runtimeId: string) => {
|
||||
for (const selection of selections) if (selection.runtimeId === runtimeId) { selection.active = false; selections.delete(selection); }
|
||||
for (const [handle, turn] of turns) if (turn.runtimeId === runtimeId) cancel(handle);
|
||||
for (const [handle, model] of models) if (model.runtimeId === runtimeId) models.delete(handle);
|
||||
for (const [handle, source] of sources) if (source.runtimeId === runtimeId) sources.delete(handle);
|
||||
@@ -50,12 +53,19 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
if (kind === 'model.select') {
|
||||
const selected = options.getModel();
|
||||
if (!selected || !selected.config.enabled || !selected.config.defaultModel) throw Error('Choose a configured model first');
|
||||
if (models.size >= 32) throw Error('Model handle limit reached');
|
||||
const handle = crypto.randomUUID();
|
||||
const model = createPluginModelAdapter({ config: selected.config, label: selected.label, transportTag: runtimeId,
|
||||
isCurrent: () => !closed && options.isActive() && options.getModel()?.revision === selected.revision });
|
||||
models.set(handle, { runtimeId, model });
|
||||
return { hostHandle: handle, label: selected.label };
|
||||
if (models.size + selections.size >= 32) throw Error('Model handle limit reached');
|
||||
const selection = { runtimeId, active: true };
|
||||
selections.add(selection);
|
||||
try {
|
||||
await options.prepareModel(selected.revision);
|
||||
signal.throwIfAborted(); assertActive();
|
||||
if (!selection.active || options.getModel()?.revision !== selected.revision) throw Error('Selected model changed');
|
||||
const handle = crypto.randomUUID();
|
||||
const model = createPluginModelAdapter({ config: selected.config, label: selected.label, transportTag: runtimeId,
|
||||
isCurrent: () => !closed && options.isActive() && options.getModel()?.revision === selected.revision });
|
||||
models.set(handle, { runtimeId, model });
|
||||
return { hostHandle: handle, label: selected.label };
|
||||
} finally { selections.delete(selection); }
|
||||
}
|
||||
if (kind === 'model.release') { owned(models, payload.hostHandle, runtimeId); models.delete(String(payload.hostHandle)); return { ok: true }; }
|
||||
if (kind === 'model.current') return { current: await owned(models, payload.hostHandle, runtimeId).model.isCurrent() };
|
||||
@@ -138,6 +148,6 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
throw Error('Unsupported host operation');
|
||||
},
|
||||
releaseRuntime,
|
||||
shutdown() { closed = true; for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
|
||||
shutdown() { closed = true; for (const selection of selections) selection.active = false; selections.clear(); for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -9,12 +9,24 @@ async function fixture(t, options = {}) {
|
||||
const root = path.resolve(__dirname, "../examples/plugins/assistant");
|
||||
const dom = new JSDOM(fs.readFileSync(path.join(root, "panel.html"), "utf8"), { runScripts: "outside-only" });
|
||||
t.after(() => dom.window.close());
|
||||
const stylesheet = dom.window.document.createElement("style");
|
||||
stylesheet.textContent = fs.readFileSync(path.join(root, "panel.css"), "utf8");
|
||||
dom.window.document.head.append(stylesheet);
|
||||
let now = 0; let opens = 0; let modelCalls = 0; let changed = false;
|
||||
const calls = [];
|
||||
const environmentListeners = new Set(); let environment;
|
||||
const element = (id) => dom.window.document.getElementById(id);
|
||||
dom.window.Date.now = options.clock ?? (() => now);
|
||||
Object.defineProperty(dom.window, "netcattyView", { value: {
|
||||
onDidReceiveMessage: () => () => {},
|
||||
onDidChangeEnvironment: (listener) => {
|
||||
environmentListeners.add(listener);
|
||||
if (environment) {
|
||||
const replay = environment;
|
||||
queueMicrotask(() => { if (environmentListeners.has(listener) && replay === environment) listener(replay); });
|
||||
}
|
||||
return { dispose: () => environmentListeners.delete(listener) };
|
||||
},
|
||||
executeCommand: async (id, params) => {
|
||||
const operation = id.replace("com.netcatty.assistant.", ""); calls.push({ operation, params });
|
||||
if (options.commands) return options.commands(operation, structuredClone(params));
|
||||
@@ -45,11 +57,43 @@ async function fixture(t, options = {}) {
|
||||
dom.window.eval(fs.readFileSync(path.join(root, "panel.js"), "utf8"));
|
||||
await waitFor(() => element("status").textContent === "Ready");
|
||||
element("prompt").value = "Synthetic draft must survive session recovery";
|
||||
return { element, calls, waitFor, opens: () => opens, modelCalls: () => modelCalls,
|
||||
return { element, calls, waitFor, window: dom.window, opens: () => opens, modelCalls: () => modelCalls,
|
||||
emitEnvironment: (value) => { environment = value; for (const listener of environmentListeners) listener(value); },
|
||||
environmentListeners: () => environmentListeners.size,
|
||||
timeTo: (value) => { now = value; }, changeModel: () => { changed = true; },
|
||||
send: () => element("compose").dispatchEvent(new dom.window.Event("submit", { cancelable: true })) };
|
||||
}
|
||||
|
||||
test("assistant follows host light/dark and accent updates with safe fallbacks and subscription cleanup", async (t) => {
|
||||
const f = await fixture(t); const root = f.window.document.documentElement;
|
||||
const computed = () => f.window.getComputedStyle(root);
|
||||
const calls = f.calls.length;
|
||||
assert.equal(computed().colorScheme, "light dark");
|
||||
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
|
||||
assert.equal(f.environmentListeners(), 1);
|
||||
f.emitEnvironment({ theme: "light", themeTokens: { "--background": "20 20% 98%", "--primary": "145 60% 35%", "--muted": "30 10% 90%", "--private-token": "ignored" } });
|
||||
assert.equal(root.style.colorScheme, "light"); assert.equal(root.dataset.theme, "light");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "145 60% 35%");
|
||||
assert.equal(root.style.getPropertyValue("--private-token"), "");
|
||||
f.emitEnvironment({ theme: "dark", themeTokens: { "--background": "220 20% 10%", "--primary": "280 80% 75%" } });
|
||||
assert.equal(root.style.colorScheme, "dark");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "280 80% 75%");
|
||||
assert.equal(root.style.getPropertyValue("--muted"), "");
|
||||
assert.equal(computed().getPropertyValue("--muted").trim(), "219 27% 21%");
|
||||
f.emitEnvironment({ theme: "light", themeTokens: { "--primary": "x".repeat(97) } });
|
||||
assert.equal(root.style.colorScheme, "light"); assert.equal(root.style.getPropertyValue("--primary"), "");
|
||||
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
|
||||
f.emitEnvironment({ theme: "system" }); assert.equal(root.style.colorScheme, "light dark");
|
||||
assert.equal(f.calls.length, calls); assert.equal(f.modelCalls(), 0);
|
||||
f.window.dispatchEvent(new f.window.Event("pagehide")); assert.equal(f.environmentListeners(), 0);
|
||||
f.emitEnvironment({ theme: "dark", themeTokens: { "--primary": "210 90% 70%" } });
|
||||
assert.equal(root.style.colorScheme, "light dark");
|
||||
f.window.dispatchEvent(new f.window.Event("pageshow"));
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(f.environmentListeners(), 1); assert.equal(root.style.colorScheme, "dark");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "210 90% 70%");
|
||||
});
|
||||
|
||||
test("expired sample chat reopens before one explicit send and preserves the draft", async (t) => {
|
||||
const f = await fixture(t); f.timeTo(300_001); f.send();
|
||||
await f.waitFor(() => !f.element("prompt").disabled);
|
||||
|
||||
Reference in new issue
Block a user