Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2498,7 +2498,6 @@
"./provider-connection-status": 1,
"./provider-display": 1,
"./provider-oauth-section": 1,
"./settings-error-copy": 1,
"./settings-route-focus": 1,
"./settings-route-header": 1,
"@astryxdesign/core": 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import type { RuntimeHostManagementServices } from '../../renderer/features/runt

// Keep renderer implementations and their asset imports out of the main compilation graph.
interface RenderModules {
ProvidersPanel: ComponentType<{
bridge: ConnectionsBridge;
initialConnectionSlug?: string;
}>;
ConnectionDetail: ComponentType<{
bridge: ConnectionsBridge;
connection: ProjectedLlmConnection;
Expand Down Expand Up @@ -97,6 +101,7 @@ before(async () => {
await build({
stdin: {
contents: [
"export { ProvidersPanel } from './settings/providers-panel';",
"export { ConnectionDetail } from './settings/provider-connection-detail';",
"export { RuntimeHostSettingsTarget } from './settings/runtime-host-settings-target';",
"export { AddProviderForm } from './settings/provider-add-form';",
Expand Down Expand Up @@ -524,6 +529,141 @@ function deferred<T>() {
return { promise, resolve: resolvePromise, reject: rejectPromise };
}

test('setting a default with one enabled model commits directly', async () => {
const harness = installRenderer();
const connection = { ...relayConnection(), defaultModel: '' };
const calls: unknown[] = [];
const bridge = connectionDetailBridge({
hasSecret: async () => true,
getSnapshot: async () => ({ connections: [connection], defaultConnection: 'relay-a', chatModelChoices: [] }),
setDefault: async (identity, modelId) => { calls.push([identity, modelId]); },
});
await harness.render('zh-CN', providersPanel(bridge, connection.slug));
await clickText(harness.document, '设为默认');
assert.deepEqual(calls, [[{ connectionId: connection.connectionId, slug: connection.slug }, connection.enabledModelIds![0]]]);
assert.equal(harness.document.querySelectorAll('dialog[open]').length, 0);
});

test('a connection with no enabled models asks for a choice and cancel writes nothing', async () => {
const harness = installRenderer();
const connection = { ...relayConnection(), defaultModel: '', enabledModelIds: [] };
const bridge = connectionDetailBridge({
hasSecret: async () => true,
getSnapshot: async () => ({ connections: [connection], defaultConnection: 'relay-a', chatModelChoices: [] }),
});
await harness.render('zh-CN', providersPanel(bridge, connection.slug));
await clickText(harness.document, '设为默认');
const dialog = harness.document.querySelector('dialog[open]');
assert.ok(dialog);
assert.match(dialog.textContent!, /这个连接尚未启用模型/);
const confirm = [...dialog.querySelectorAll('button')].find(button => button.textContent === '设为默认');
assert.ok(confirm?.disabled);
await clickText(dialog, '取消');
assert.equal(harness.document.querySelectorAll('dialog[open]').length, 0);
});

test('choosing an unenabled default keeps the dialog on failure and updates the badge only on success', async () => {
const harness = installRenderer();
let connection = { ...relayConnection(), defaultModel: '', enabledModelIds: [] as string[] };
let defaultConnection = 'relay-a';
const calls: unknown[] = [];
const bridge = connectionDetailBridge({
hasSecret: async () => true,
getSnapshot: async () => ({ connections: [connection], defaultConnection, chatModelChoices: [] }),
setDefault: async (identity, modelId) => {
calls.push([identity, modelId]);
if (calls.length === 1) throw new Error('DEFAULT_CONNECTION_CHANGED');
connection = { ...connection, defaultModel: modelId!, enabledModelIds: [modelId!] };
defaultConnection = connection.slug;
},
});
await harness.render('zh-CN', providersPanel(bridge, connection.slug));
await clickText(harness.document, '设为默认');
const selector = harness.document.querySelector<HTMLButtonElement>('dialog[open] [role="combobox"]');
assert.ok(selector);
await act(async () => selector.click());
const option = [...harness.document.querySelectorAll<HTMLElement>('[role="option"]')].find(element => element.textContent?.includes('gpt-5.6-sol-joybuilder'));
assert.ok(option);
await act(async () => option.click());
await clickText(harness.document.querySelector('dialog[open]')!, '启用并设为默认');
assert.equal(defaultConnection, 'relay-a');
assert.match(harness.document.querySelector('dialog[open]')!.textContent!, /连接配置已更新/);
await clickText(harness.document.querySelector('dialog[open]')!, '启用并设为默认');
assert.equal(calls.length, 2);
assert.deepEqual(calls[1], [{ connectionId: connection.connectionId, slug: connection.slug }, 'gpt-5.6-sol-joybuilder']);
assert.equal(defaultConnection, connection.slug);
assert.equal(harness.document.querySelectorAll('dialog[open]').length, 0);
assert.equal(harness.document.querySelector('.settingsActionSlotBadge')?.textContent, '默认');
});

test('fetching choices in the default dialog preserves the selection and offers every discovered model', async () => {
const harness = installRenderer();
let connection: ProjectedLlmConnection = { ...relayConnection(), defaultModel: '', enabledModelIds: [], models: [], catalogEntries: [] };
const bridge = connectionDetailBridge({
hasSecret: async () => true,
getSnapshot: async () => ({ connections: [connection], defaultConnection: 'relay-a', chatModelChoices: [] }),
fetchModels: async (identity, options) => {
assert.deepEqual(identity, { connectionId: connection.connectionId, slug: connection.slug });
assert.deepEqual(options, { preserveSelection: true });
connection = { ...connection, models: [{ id: 'discovered-first' }, { id: 'discovered-second' }],
catalogEntries: ['discovered-first', 'discovered-second'].map(id => ({ ...relayConnection().catalogEntries[0]!, id, isDefault: false })),
};
return { models: connection.models!, source: 'fetched' };
},
});
await harness.render('zh-CN', providersPanel(bridge, connection.slug));
await clickText(harness.document, '设为默认');
await clickText(harness.document.querySelector('dialog[open]')!, '获取模型');
const selector = harness.document.querySelector<HTMLButtonElement>('dialog[open] [role="combobox"]');
assert.ok(selector);
await act(async () => selector.click());
const offered = [...harness.document.querySelectorAll('[role="option"]')].map(option => option.textContent);
assert.ok(offered.includes('discovered-first'));
assert.ok(offered.includes('discovered-second'));
await clickText(harness.document.querySelector('dialog[open]')!, '取消');
assert.deepEqual(connection.enabledModelIds, []);
});

test('multiple enabled models require a choice, and an empty catalog offers model setup', async () => {
const harness = installRenderer();
let connection: ProjectedLlmConnection = { ...relayConnection(), defaultModel: '',
enabledModelIds: ['first', 'second'],
catalogEntries: ['first', 'second'].map(id => ({ ...relayConnection().catalogEntries[0]!, id, isDefault: false })),
};
const bridge = connectionDetailBridge({
hasSecret: async () => true,
getSnapshot: async () => ({ connections: [connection], defaultConnection: 'relay-a', chatModelChoices: [] }),
fetchModels: async () => {
connection = { ...relayConnection(), defaultModel: '', enabledModelIds: [] };
return { models: connection.models!, source: 'fetched' };
},
});
await harness.render('zh-CN', providersPanel(bridge, connection.slug));
await clickText(harness.document, '设为默认');
assert.match(harness.document.querySelector('dialog[open]')!.textContent!, /选择这个连接用于新任务/);
await clickText(harness.document.querySelector('dialog[open]')!, '取消');
connection = { ...connection, connectionId: 'empty-relay', slug: 'empty-relay', models: [], catalogEntries: [], enabledModelIds: [] };
// A new panel instance reads the empty connection instead of reusing the old route.
await harness.render('zh-CN', providersPanel(bridge, connection.slug, 'empty'));
await clickText(harness.document, '设为默认');
assert.match(harness.document.querySelector('dialog[open]')!.textContent!, /请先获取或手动添加模型/);
await clickText(harness.document.querySelector('dialog[open]')!, '手动添加模型');
assert.match(harness.document.querySelector('dialog[open]')!.textContent!, /模型 ID/);
});

function providersPanel(bridge: ConnectionsBridge, slug: string, key?: string) {
return createElement(components.RuntimeHostSettingsTarget, {
host: { profileId: 'local', hostId: 'host-local' },
children: createElement(components.ProvidersPanel, { key, bridge, initialConnectionSlug: slug }),
});
}

async function clickText(container: ParentNode, label: string) {
const button = [...container.querySelectorAll('button')].find(button => button.textContent === label);
assert.ok(button, `missing button: ${label}`);
await act(async () => button.click());
}

function relayConnection(): ProjectedLlmConnection {
const modelId = 'gpt-5.6-sol-joybuilder';
return {
Expand Down Expand Up @@ -611,8 +751,8 @@ function installRenderer() {
}) as unknown as CSSStyleDeclaration;
Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} });
Object.assign(window.HTMLElement.prototype, {
showModal(this: HTMLElement) { this.setAttribute('open', ''); },
close(this: HTMLElement) { this.removeAttribute('open'); },
showModal(this: HTMLElement & { open: boolean }) { this.open = true; this.setAttribute('open', ''); },
close(this: HTMLElement & { open: boolean }) { this.open = false; this.removeAttribute('open'); },
});
Object.assign(globalThis, {
document, window, matchMedia, getComputedStyle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -653,11 +653,17 @@ test('renderer service saves through IPC into the canonical catalog and reads it
})) };
},
updateConnection: (expected: UpdateCatalogConnectionInput['expected'], changes: UpdateCatalogConnectionInput['changes']) => stores.connectionCatalog.update({ expected, changes }),
setDefaultConnectionTarget: (expectedCatalogRevision: number, target: { connectionId: string; modelId: string } | null, enableModel?: boolean) =>
stores.connectionCatalog.setDefaultTarget({ expectedCatalogRevision, target, ...(enableModel === undefined ? {} : { enableModel }) }),
} as never,
emitConnectionListChanged() {},
});
const host = { profileId: 'profile', hostId: 'host' };
const services = createDesktopConnectionSettingsServices(() => ({ connections: {
setDefault: (identity: unknown, target: unknown, modelId: unknown) => {
assert.deepEqual(target, host);
return handlers.get('connections:setDefault')!({}, identity, modelId);
},
update: (identity: unknown, patch: unknown, target: unknown) => {
assert.deepEqual(target, host);
return handlers.get('connections:update')!({}, identity, patch);
Expand All @@ -680,6 +686,15 @@ test('renderer service saves through IPC into the canonical catalog and reads it
assert.deepEqual((await stores.connectionCatalog.getSnapshot()).connections[0], saved);
await services.update(identity, { modelOverride: { modelId: 'manual', expected: value, value: {} } });
assert.deepEqual((await services.getSnapshot()).connections[0]?.modelOverrides, { other: { vision: true }, manual: {} });
await services.update(identity, { enabledModelIds: [] });
const beforeDefault = await stores.connectionCatalog.getSnapshot();
await services.setDefault(identity, 'manual');
const defaulted = await stores.connectionCatalog.getSnapshot();
assert.equal(defaulted.revision, beforeDefault.revision + 1);
assert.deepEqual(defaulted.defaultTarget, { connectionId: identity.connectionId, modelId: 'manual' });
assert.deepEqual((await services.getSnapshot()).connections[0]?.enabledModelIds, ['manual']);
await assert.rejects(services.setDefault(identity, 'missing'), /DEFAULT_MODEL_UNAVAILABLE/);
assert.deepEqual(await stores.connectionCatalog.getSnapshot(), defaulted);
} finally {
await owner.close();
await rm(root, { recursive: true, force: true });
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,10 +506,12 @@ export class DesktopRuntimeHostClient {
setDefaultConnectionTarget(
expectedCatalogRevision: number,
target: OperationInput<"connection.catalog.set-default-target">["target"],
enableModel?: boolean,
): Promise<OperationOutput<"connection.catalog.set-default-target">> {
return this.request("connection.catalog.set-default-target", {
expectedCatalogRevision,
target,
...(enableModel === undefined ? {} : { enableModel }),
});
}

Expand Down Expand Up @@ -540,8 +542,9 @@ export class DesktopRuntimeHostClient {

fetchConnectionModels(
connectionId: string,
preserveSelection?: boolean,
): Promise<OperationOutput<"connection.models.fetch">> {
return this.request("connection.models.fetch", { connectionId });
return this.request("connection.models.fetch", { connectionId, ...(preserveSelection === undefined ? {} : { preserveSelection }) });
}

testConnection(
Expand Down
26 changes: 19 additions & 7 deletions apps/desktop/src/main/runtime-host-connections-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,24 @@ export function registerRuntimeHostConnectionsIpc(
return { names: result.names } satisfies SavedRequestHeaders;
},
);
deps.ipcMain.handle('connections:setDefault', async (_event, identity: unknown) => {
deps.ipcMain.handle('connections:setDefault', async (_event, identity: unknown, modelId?: unknown) => {
const catalog = await snapshot();
const connection = identity === null ? null : requireConnectionIdentity(catalog, identity);
if (modelId !== undefined && (typeof modelId !== 'string' || !modelId.trim() || !connection)) {
throw new Error('Invalid default model selection');
}
const target = identity === null
? null
: defaultTargetForConnection(requireConnectionIdentity(catalog, identity));
requireCommitted(
await deps.client.setDefaultConnectionTarget(catalog.revision, target),
'set default Connection',
: typeof modelId === 'string'
? { connectionId: connection!.connectionId, modelId }
: defaultTargetForConnection(connection!);
const enableModel = typeof modelId === 'string' && !connection!.enabledModelIds.includes(modelId);
const result = await deps.client.setDefaultConnectionTarget(
catalog.revision, target, enableModel ? true : undefined,
);
if (result.kind === 'revision_conflict') throw new Error('DEFAULT_CONNECTION_CHANGED');
if (result.kind === 'invalid_default_target') throw new Error('DEFAULT_MODEL_UNAVAILABLE');
requireCommitted(result, 'set default Connection');
deps.emitConnectionListChanged();
});
deps.ipcMain.handle('connections:setDefaultBySlug', async (_event, slug: unknown) => {
Expand Down Expand Up @@ -334,9 +343,12 @@ export function registerRuntimeHostConnectionsIpc(
throw new Error('Unable to delete Connection: connection_stale');
}
});
deps.ipcMain.handle('connections:fetchModels', async (_event, identity: unknown) => {
deps.ipcMain.handle('connections:fetchModels', async (_event, identity: unknown, options?: { preserveSelection?: unknown }) => {
const current = requireConnectionIdentity(await snapshot(), identity);
const result = await deps.client.fetchConnectionModels(current.connectionId);
if (options?.preserveSelection !== undefined && typeof options.preserveSelection !== 'boolean') {
throw new Error('Invalid model discovery selection policy');
}
const result = await deps.client.fetchConnectionModels(current.connectionId, options?.preserveSelection);
if (result.kind !== 'committed') {
throw new Error(`Unable to fetch Connection models: ${result.kind}`);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,7 +1566,7 @@ export interface MakaBridge {
};
connections: {
getSnapshot(sessionId?: string, host?: DesktopRuntimeHostRef): Promise<DesktopConnectionSnapshot>;
setDefault(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise<void>;
setDefault(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef, modelId?: string): Promise<void>;
setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise<void>;
create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').IdentifiedLlmConnection>;
verifyOnboarding(
Expand All @@ -1580,7 +1580,7 @@ export interface MakaBridge {
update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection>;
delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<void>;
test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<Pick<ModelDiscoveryResult, 'models' | 'source'>>;
fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef, options?: { preserveSelection?: boolean }): Promise<Pick<ModelDiscoveryResult, 'models' | 'source'>>;
hasSecret(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<boolean>;
getRequestHeaders(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').SavedRequestHeaders>;
setRequestHeaders(
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3180,11 +3180,12 @@ const makaBridge = {
? invokeRuntimeHostForSession('connections:getSnapshot', sessionId)
: invokeSelectedRuntimeHost(host, 'connections:getSnapshot');
},
setDefault(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise<void> {
setDefault(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef, modelId?: string): Promise<void> {
return invokeSelectedRuntimeHost(
host,
typeof connection === 'string' ? 'connections:setDefaultBySlug' : 'connections:setDefault',
connection,
modelId,
);
},
setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise<void> {
Expand Down Expand Up @@ -3213,8 +3214,8 @@ const makaBridge = {
opts,
);
},
fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<Pick<ModelDiscoveryResult, 'models' | 'source'>> {
return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection);
fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef, options?: { preserveSelection?: boolean }): Promise<Pick<ModelDiscoveryResult, 'models' | 'source'>> {
return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection, options);
},
hasSecret(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<boolean> {
return invokeSelectedRuntimeHost(host, 'connections:hasSecret', connection);
Expand Down
Loading