Skip to content

Commit 143cf96

Browse files
L4b0Rclaude
andauthored
feat(desktop): add Tavily web search settings (#439)
Add explicit Tavily web-search settings with safe key save/replace/clear actions, bounded connection testing, and per-run configuration snapshots. Keep research consent independent from provider configuration and exclude search-only credentials from model service listings. Maintainer validation of f90c761 against main 26c8498: - Frozen-lockfile install, lint, all workspace typechecks, and Electron production build passed on Windows with Node 24.11.0 and pnpm 10.33.4. - Full uncached suite passed with one worker: all 10 Turbo tasks, 3721 Vitest tests passed and 3 existing opt-in exporter tests skipped. - Focused desktop 277/277 and shared 14/14 tests passed. An initial concurrent cold-import timeout passed on isolated and single-worker reruns without code changes. - Actual Electron 39.8.10 UI smoke passed with isolated temporary data and synthetic credentials, including save/replace/clear and persistence across a real restart. No live Tavily or model request was made. - Existing Node 22 CI, CodeQL and dependency review checks passed for this unchanged PR head. Signed-off-by: HUANG <15866338256@163.com> Signed-off-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> Co-Authored-By: HUANG <15866338256@163.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent 26c8498 commit 143cf96

26 files changed

Lines changed: 1731 additions & 48 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@open-codesign/desktop": minor
3+
"@open-codesign/shared": patch
4+
"@open-codesign/i18n": patch
5+
---
6+
7+
Add bilingual Tavily web-search settings with safe key save/replace/clear operations and explicit bounded connection testing. Apply settings to new runs without restarting while retaining per-run networking consent and active-run snapshots. Keep direct webpage reading independent of Tavily credentials, and exclude search-only credentials from the model API service list without deleting them.

‎WEB_SEARCH.md‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,35 @@ Open CoDesign can research a slide topic, save facts/data, build slides, and del
44

55
## Configure
66

7-
Finish normal model onboarding first. Close the app and add these sections to the active `config.toml` (normally `~/.config/open-codesign/config.toml`; respects `XDG_CONFIG_HOME` and custom storage locations):
7+
Open **Settings → Web Search** (**设置 → 联网搜索**). No manual file editing or restart is needed:
8+
9+
1. Paste a Tavily API key into the password field and choose **Save key**. Obtain a key from the [official Tavily dashboard](https://app.tavily.com). The UI shows **Configured**, never the saved key. Enter a new key and save to replace it; an empty input does not erase it.
10+
2. Turn **Web search** on or off. This retains the saved key and existing custom limits. Saving other settings also retains the search settings and credentials.
11+
3. Optionally click **Test connection** to test the **saved** key. Unsaved input is not tested. This explicit action works even with search disabled and does not enable search or authorize any task.
12+
4. **Clear key** removes only the Tavily credential. It does not change the search switch, custom limits, model credentials, or key-independent public webpage reading.
13+
14+
Changes apply to subsequent new runs (including the next turn in an existing design). Running tasks retain their starting configuration and credential snapshot; clearing or replacing a key does not implicitly change an in-flight task. Stop an active task if it must no longer use its previously authorized access. Saving/enabling never grants blanket networking permission: each new run still uses the existing **Allow this run / Deny** dialog.
15+
16+
Search queries are sent to Tavily; webpage requests go directly to the requested public host. Never paste a key into chat or a design workspace. Key input is transient UI state and is cleared after saving; saved credentials are resolved only in the main process. Get/save/test IPC responses contain only configuration status or bounded diagnostic codes, never credentials, request headers, provider response bodies, or raw errors. No new credential store is introduced: the existing OS-backed `safe:` storage (or `plain:` fallback when unavailable) and active app configuration are reused.
17+
18+
### Test connection
19+
20+
The button sends exactly one bounded `POST https://api.tavily.com/search` request with Bearer authentication, fixed query `Tavily`, `max_results: 1`, `search_depth: "basic"`, `auto_parameters: false`, and no generated answer or raw content. It is not run on open, save, toggle, or clear. It consumes **1 Tavily API credit** for a successful basic search, but does **not** consume a task's call budget, grant/bypass task authorization, or save research/chat/workspace records. There is no automatic retry or fallback.
21+
22+
The [official Search API reference](https://docs.tavily.com/documentation/api-reference/endpoint/search) documents this request, credit cost, and error statuses (also available as [Markdown/OpenAPI](https://docs.tavily.com/documentation/api-reference/endpoint/search.md)):
23+
24+
- `401`: missing/invalid API key (the app checks for missing local credentials before requesting).
25+
- `429`: rate limited; wait before retrying.
26+
- `432` / `433`: key/plan usage limit or pay-as-you-go limit exceeded; check the Tavily dashboard.
27+
- Local deadline expiry: timeout, not evidence that the key is invalid.
28+
- DNS/TLS/connection failures: network failure, without guessing the specific cause.
29+
- Server errors or other HTTP statuses: service/unknown response with the HTTP status, not a claim of invalid credentials. Malformed successful replies are reported as unexpected responses, not success.
30+
31+
Unreadable encrypted credentials are shown as needing replacement, without exposing decryption errors. A successful test confirms that single request only, not future quota or service availability.
32+
33+
### Existing manual configuration
34+
35+
Existing supported manual configuration and custom storage locations remain compatible. If configuring manually while the app is closed, the active `config.toml` (normally `~/.config/open-codesign/config.toml`; respects `XDG_CONFIG_HOME` and custom storage locations) supports:
836

937
```toml
1038
[webSearch]
@@ -17,13 +45,13 @@ maxChars = 10000
1745
ciphertext = "plain:YOUR_TAVILY_API_KEY"
1846
```
1947

20-
Restart the app. Do not paste the key into chat or a design workspace. The `ciphertext` name is the existing credential-storage format; `plain:` is its supported human-readable local form. Existing `safe:` credentials are also handled by the main-process credential resolver. Provider settings and OAuth changes retain the search configuration.
48+
The `ciphertext` name is the existing credential-storage format; `plain:` is its supported human-readable local form. Existing `safe:` and readable legacy encrypted credentials are also handled by the main-process resolver. Provider settings and OAuth changes retain the search configuration. Manual edits made outside the app are read on launch, not watched live; use Settings for immediate updates to new runs.
2149

2250
If an older build fails to start with `Failed to decrypt a legacy API key`, an entry without `plain:` or `safe:` is being interpreted as legacy encrypted data. Check the entry you added: a newly copied Tavily key must be `ciphertext = "plain:tvly-..."`, not just `ciphertext = "tvly-..."`. Do not prefix existing encrypted values with `plain:`; replace them with a freshly copied key if needed. The log alone does not identify which entry failed. Credential migration now preserves unreadable entries and logs their provider ID without aborting startup; that credential still needs repair before use.
2351

2452
Search is disabled by default. Its first network call asks through the existing structured-question dialog for this run's bounded public-web access: choose **Allow this run** or **Deny**. Denial or cancellation prevents the request. Permission is not carried into later runs; this v1 does not persist a network allowlist. A missing Tavily key is an explicit search error, not an empty result or simulated success. Public webpage reading does not require a Tavily key. Search queries are sent to Tavily; page requests go directly to the requested public host. Credentials are never tool arguments or results.
2553

26-
If a tool reports **Web access is disabled**, the run loaded a missing or false `webSearch.enabled`; this is not an HTTP error from the target website. A Tavily key alone does not enable networking. Fully quit the app, check the active config directory shown in Settings (not a workspace config), add or update the top-level `[webSearch]` section above, then restart and start a new turn. Older builds could drop this section when saving provider/model, import, image or design-system settings; those save paths now preserve it, including an explicit `false`. If an older build already removed the section, it needs to be added again.
54+
If a tool reports **Web access is disabled**, open **Settings → Web Search**, enable it, then start a new turn. If Tavily credentials are missing or unreadable, save/replace the key there and start a new turn. These errors include Settings guidance; they are not HTTP errors from a target website. A Tavily key alone does not enable networking. Older builds could drop the search section when saving provider/model, import, image or design-system settings; current save paths preserve it, including an explicit `false`. If it was previously lost, enable it again in Settings.
2755

2856
Settings limits: `maxCalls` 1–50 (search and fetch combined per run, including failed network attempts); `timeoutMs` 1,000–60,000; `maxChars` 1,000–12,000 (per fetched body). Search allows 1–5 results and at most 2,000 snippet characters per result. Each HTTP response is limited to 1 MiB, and page fetches allow at most five redirects. Records are limited to 8 MiB per workspace.
2957

@@ -90,6 +118,9 @@ Existing lightweight HTML string helpers are not a full HTML5 parser; using a br
90118
Focused checks:
91119

92120
```sh
93-
pnpm --filter @open-codesign/desktop exec vitest run src/main/web-research-network.test.ts src/main/web-research.test.ts src/main/exporter-ipc.test.ts
121+
pnpm --filter @open-codesign/shared exec vitest run src/config.test.ts src/web-search-settings.test.ts
122+
pnpm --filter @open-codesign/desktop exec vitest run src/main/web-search-settings.test.ts src/main/onboarding-ipc.test.ts src/main/web-research-network.test.ts src/main/web-research-run.test.ts src/main/keychain.test.ts src/main/onboarding/config-cache.test.ts
123+
pnpm --filter @open-codesign/desktop exec vitest run src/renderer/src/components/settings/WebSearchTab.test.tsx src/renderer/src/components/Settings.test.ts src/preload/web-search.test.ts
124+
pnpm --filter @open-codesign/desktop exec vitest run src/main/web-research.test.ts src/main/exporter-ipc.test.ts
94125
pnpm --filter @open-codesign/core exec vitest run src/tool-manifest.test.ts src/agent.test.ts
95126
```

‎apps/desktop/src/main/ipc/generate.ts‎

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ import {
9090
import { registerSourceEditBusyCheck } from '../source-edits-ipc';
9191
import { withTlsBypass } from '../tls-override';
9292
import { createResearchHost, createWebResearchAuthorization } from '../web-research';
93-
import { createWebResearchNetwork } from '../web-research-network';
93+
import { createWebResearchRun } from '../web-research-run';
9494
import { withStableWorkspacePath } from '../workspace-path-lock';
9595
import { listWorkspaceFilesAt, readWorkspaceFilesAt } from '../workspace-reader';
9696
import { finalAssistantTextForTurn } from './assistant-text';
@@ -690,6 +690,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
690690
designId: string,
691691
previousSource: string | null,
692692
workspaceRoot: string,
693+
researchRun: ReturnType<typeof createWebResearchRun>,
693694
attachmentsForRuntimeFs?: Parameters<typeof createRuntimeTextEditorFs>[0]['attachments'],
694695
memoryCallbacks?: {
695696
onAggressivePrune?: () => void;
@@ -700,6 +701,8 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
700701
publishEvent(event);
701702
};
702703
const baseCtx = { designId, generationId: id } as const;
704+
const cfg = getCachedConfig();
705+
const { settings: researchSettings, network } = researchRun;
703706
const toolStartedAt = new Map<string, number>();
704707
const templatesRoot = path_module.join(app.getPath('userData'), 'templates');
705708
const currentWorkspaceRoot = () => requireWorkspaceRootForDesign(designId).workspaceRoot;
@@ -721,32 +724,8 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
721724
frames,
722725
designSkills,
723726
});
724-
const cfg = getCachedConfig();
725-
const researchSettings = cfg?.webSearch ?? {
726-
enabled: false,
727-
maxCalls: 12,
728-
timeoutMs: 15000,
729-
maxChars: 10000,
730-
};
731-
// Keep credentials in this process and resolve only when a network tool is used.
732-
let network: ReturnType<typeof createWebResearchNetwork> | undefined;
733-
const getResearchNetwork = () => {
734-
if (!network) {
735-
const stored = cfg?.secrets['tavily'];
736-
network = createWebResearchNetwork({
737-
...researchSettings,
738-
...(stored && researchSettings.enabled
739-
? { apiKey: decryptSecret(stored.ciphertext) }
740-
: {}),
741-
});
742-
}
743-
return network;
744-
};
745727
const research = createResearchHost({
746-
network: {
747-
search: (query, count, signal) => getResearchNetwork().search(query, count, signal),
748-
fetch: (url, signal) => getResearchNetwork().fetch(url, signal),
749-
},
728+
network,
750729
inWorkspace: (fn) => withStableWorkspacePath(designId, () => fn(currentWorkspaceRoot())),
751730
authorize: createWebResearchAuthorization(researchSettings, (questions, signal) =>
752731
requestAsk(id, questions, () => getMainWindow(), {
@@ -1116,6 +1095,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
11161095
'CONFIG_MISSING',
11171096
);
11181097
}
1098+
const researchRun = createWebResearchRun(cfg, decryptSecret);
11191099
const active = resolveActiveModel(cfg, payload.model);
11201100
const allowKeyless = active.allowKeyless;
11211101
const apiKey = await resolveApiKeyForActive(active.model.provider, allowKeyless);
@@ -1357,6 +1337,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
13571337
designId,
13581338
payload.previousSource ?? null,
13591339
workspaceRoot,
1340+
researchRun,
13601341
promptContext.attachments,
13611342
{
13621343
onAggressivePrune: () => {
@@ -1626,6 +1607,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
16261607
'CONFIG_MISSING',
16271608
);
16281609
}
1610+
const researchRun = createWebResearchRun(cfg, decryptSecret);
16291611
// Inline-comment edits don't need to be tied to whatever provider was
16301612
// pinned in the original generate; resolve fresh against the canonical
16311613
// active provider so a switch in Settings takes effect immediately.
@@ -1714,6 +1696,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
17141696
payload.designId,
17151697
payload.artifactSource,
17161698
workspaceRoot,
1699+
researchRun,
17171700
promptContext.attachments,
17181701
),
17191702
);

‎apps/desktop/src/main/ipc/generate.workspace-rename.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ vi.mock('../onboarding-ipc', () => ({
137137
getCachedConfig: vi.fn(() => ({
138138
provider: 'mock-provider',
139139
modelPrimary: 'mock-model',
140+
secrets: {},
140141
designSystem: null,
141142
})),
142143
hasApiKeyForProvider: vi.fn(() => true),

‎apps/desktop/src/main/onboarding-ipc.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,48 @@ vi.mock('@open-codesign/providers', () => ({
132132
}));
133133

134134
describe('registerOnboardingIpc — channel versioning', () => {
135+
it('routes search settings through safe versioned get/save/test handlers', async () => {
136+
const { hydrateConfig, WEB_SEARCH_SETTINGS_CHANNELS } = await import('@open-codesign/shared');
137+
const { setCachedConfig } = await import('./onboarding/config-cache');
138+
const network = await import('./web-research-network');
139+
const probe = vi.spyOn(network, 'testTavilyConnection').mockResolvedValue({ status: 'ok' });
140+
setCachedConfig(
141+
hydrateConfig({
142+
version: 3,
143+
activeProvider: '',
144+
activeModel: '',
145+
providers: {},
146+
secrets: {},
147+
}),
148+
);
149+
await registerIpcForTest();
150+
try {
151+
const get = handlers.get(WEB_SEARCH_SETTINGS_CHANNELS.get);
152+
const save = handlers.get(WEB_SEARCH_SETTINGS_CHANNELS.save);
153+
const test = handlers.get(WEB_SEARCH_SETTINGS_CHANNELS.test);
154+
expect(get).toBeDefined();
155+
expect(save).toBeDefined();
156+
expect(test).toBeDefined();
157+
expect(await get?.({})).toEqual({ enabled: false, hasKey: false });
158+
expect(await save?.({}, { apiKey: 'tvly-ipc-fixture' })).toEqual({
159+
enabled: false,
160+
hasKey: true,
161+
});
162+
expect(await save?.({}, { enabled: true })).toEqual({ enabled: true, hasKey: true });
163+
const listProviders = handlers.get('settings:v1:list-providers');
164+
expect(listProviders).toBeDefined();
165+
expect(await listProviders?.({})).toEqual([]);
166+
expect(await get?.({})).toEqual({ enabled: true, hasKey: true });
167+
expect(probe).not.toHaveBeenCalled();
168+
expect(await test?.({})).toEqual({ status: 'ok' });
169+
expect(probe).toHaveBeenCalledExactlyOnceWith('tvly-ipc-fixture', 15000);
170+
expect(await save?.({}, { clearKey: true })).toEqual({ enabled: true, hasKey: false });
171+
expect(await test?.({})).toEqual({ status: 'missing-key' });
172+
expect(probe).toHaveBeenCalledOnce();
173+
} finally {
174+
probe.mockRestore();
175+
}
176+
});
135177
it('registers settings:v1:list-providers without the unversioned settings:list-providers shim', async () => {
136178
await registerIpcForTest();
137179

‎apps/desktop/src/main/onboarding/register.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ERROR_CODES,
55
type ExternalConfigsDetection,
66
type OnboardingState,
7+
WEB_SEARCH_SETTINGS_CHANNELS,
78
} from '@open-codesign/shared';
89
import { ipcMain } from '../electron-runtime';
910
import { readClaudeCodeSettings } from '../imports/claude-code-config';
@@ -13,6 +14,11 @@ import { readOpencodeConfig } from '../imports/opencode-config';
1314
import { getLogger } from '../logger';
1415
import type { ProviderRow } from '../provider-settings';
1516
import type { AppPaths } from '../storage-settings';
17+
import {
18+
getWebSearchSettings,
19+
saveWebSearchSettings,
20+
testWebSearchSettings,
21+
} from '../web-search-settings';
1622
import { getCachedConfig, toState } from './config-cache';
1723
import {
1824
runImportClaudeCode,
@@ -47,6 +53,12 @@ const logger = getLogger('settings-ipc');
4753
// silently" background.
4854

4955
export function registerOnboardingIpc(): void {
56+
ipcMain.handle(WEB_SEARCH_SETTINGS_CHANNELS.get, () => getWebSearchSettings());
57+
ipcMain.handle(WEB_SEARCH_SETTINGS_CHANNELS.save, (_event, raw: unknown) =>
58+
saveWebSearchSettings(raw),
59+
);
60+
ipcMain.handle(WEB_SEARCH_SETTINGS_CHANNELS.test, () => testWebSearchSettings());
61+
5062
ipcMain.handle('onboarding:get-state', (): OnboardingState => toState(getCachedConfig()));
5163

5264
ipcMain.handle('onboarding:validate-key', async (_e, raw: unknown): Promise<ValidateResult> => {

‎apps/desktop/src/main/provider-settings.test.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type Config,
55
hydrateConfig,
66
} from '@open-codesign/shared';
7-
import { describe, expect, it } from 'vitest';
7+
import { describe, expect, it, vi } from 'vitest';
88
import {
99
assertProviderHasStoredSecret,
1010
computeDeleteProviderResult,
@@ -94,6 +94,49 @@ describe('getAddProviderDefaults', () => {
9494
});
9595

9696
describe('toProviderRows', () => {
97+
it.each([
98+
{ ciphertext: 'plain:tvly-fixture' },
99+
{ ciphertext: 'safe:unreadable-fixture' },
100+
{ ciphertext: 'legacy-fixture', mask: 'tvly***ture' },
101+
])('does not list or decrypt Tavily credentials as a model service: %j', (tavily) => {
102+
const cfg = makeCfg({
103+
provider: 'openai',
104+
modelPrimary: 'gpt-4o',
105+
secrets: { tavily },
106+
});
107+
cfg.webSearch = { enabled: true, maxCalls: 7, timeoutMs: 4000, maxChars: 3000 };
108+
const before = structuredClone(cfg);
109+
const decrypt = vi.fn(() => {
110+
throw new Error('Search credentials must not be decrypted for model settings');
111+
});
112+
113+
const rows = toProviderRows(cfg, decrypt);
114+
115+
expect(rows.map((row) => row.provider)).toEqual(['anthropic', 'openai', 'openrouter']);
116+
expect(JSON.stringify(rows)).not.toContain('tvly');
117+
expect(decrypt).not.toHaveBeenCalled();
118+
expect(cfg).toEqual(before);
119+
});
120+
121+
it('keeps recognized legacy model keys without provider entries but excludes search-only keys', () => {
122+
const cfg = hydrateConfig({
123+
version: 3,
124+
activeProvider: '',
125+
activeModel: '',
126+
providers: {},
127+
secrets: {
128+
openai: { ciphertext: 'plain:model-fixture', mask: '***' },
129+
tavily: { ciphertext: 'plain:tvly-fixture' },
130+
},
131+
});
132+
const decrypt = vi.fn(() => 'unused');
133+
134+
expect(toProviderRows(cfg, decrypt)).toEqual([
135+
expect.objectContaining({ provider: 'openai', hasKey: true, maskedKey: '***' }),
136+
]);
137+
expect(decrypt).not.toHaveBeenCalled();
138+
});
139+
97140
it('returns a row with error:decryption_failed and empty maskedKey when decrypt throws', () => {
98141
const cfg = makeCfg({
99142
provider: 'openai',

‎apps/desktop/src/main/provider-settings.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export function toProviderRows(
114114
for (const provider of allIds) {
115115
const ref = cfg.secrets?.[provider];
116116
const entry = resolveEntryFor(cfg, provider);
117+
// Secrets also contain non-model credentials such as Tavily. Only actual
118+
// model entries (or recognized legacy builtins) belong in the model list.
119+
if (entry === null) continue;
117120

118121
let maskedKey = '';
119122
let rowError: ProviderRow['error'];

0 commit comments

Comments
 (0)