diff --git a/README.md b/README.md index 91532732..1ed83328 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Without `enabled: true` it records nothing and keeps no files; without `sidebarS - **Node.js 22.19+** and **pnpm** (`npm install -g pnpm`, or `corepack enable`). - **Git**, to install from GitHub. -- An API key for at least one model provider (for example DeepSeek). +- An API key for at least one model provider (for example DeepSeek: see [Set a DeepSeek API key](docs/deepseek-api-key.md)). ### 2. Install FlyBot (core + plugins) diff --git a/README.zh.md b/README.zh.md index 8c941413..9cd64140 100644 --- a/README.zh.md +++ b/README.zh.md @@ -109,7 +109,7 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab - **Node.js 22.19+** 与 **pnpm** - **Git**(从 GitHub 安装) -- 至少一个模型提供商的 API Key(例如 DeepSeek) +- 至少一个模型提供商的 API Key(例如 DeepSeek,设置方法见 [Set a DeepSeek API key](docs/deepseek-api-key.md),英文) ### 2. 安装 FlyBot(核心 + 插件) diff --git a/changelog.d/451-deepseek-key-doc.added.md b/changelog.d/451-deepseek-key-doc.added.md new file mode 100644 index 00000000..dfe9633c --- /dev/null +++ b/changelog.d/451-deepseek-key-doc.added.md @@ -0,0 +1 @@ +A guide, [Set a DeepSeek API key](docs/deepseek-api-key.md), explains the ways to give DeepSeek Harness your key (the key dialog, its credentials file, or `DEEPSEEK_API_KEY`), and the missing-key error and the no-model setup choices link to it ([#451](https://github.com/ThinkFlowLab/FlyBot/issues/451)). diff --git a/core/client.js b/core/client.js index ef823a0f..10e088ac 100644 --- a/core/client.js +++ b/core/client.js @@ -279,6 +279,7 @@ var ZH = { "Hi, I'm {name}. Pick a model for me first. You can change it later in my details.": "你好,我是 {name}。先给我选一个模型吧,以后可以在我的详情里换。", "Sign in to DeepSeek": "登录 DeepSeek 账号", "Enter a DeepSeek API key": "填入 DeepSeek API key", + "How to set a DeepSeek API key": "如何设置 DeepSeek API key", "Open settings to set up a custom model": "打开设置配置自定义模型", "FlyBot": "FlyBot", "FlyBot core": "FlyBot 核心", @@ -8500,7 +8501,7 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { }).finally(() => setBusy("")); }; const text = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); - const choices = models.length === 0 ? setupChoices(actions).map(([label, run]) => (0, import_react15.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)) : [ + const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react15.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react15.createElement)(KeyHelpLink, { key: "key-help" })] : [ ...groupModels(models).map(([provider, list]) => (0, import_react15.createElement)( "div", { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider }, @@ -8566,6 +8567,10 @@ function setupChoices(actions) { [t("Open settings to set up a custom model"), openHarnessModels] ].filter(Boolean); } +var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md"; +function KeyHelpLink() { + return (0, import_react15.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); +} function missingModelKey(message) { const text = String(message ?? ""); if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text)) return void 0; @@ -8587,6 +8592,7 @@ function TurnErrorCell({ node, t: chatT, actions }) { const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed")); const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw; const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : []; + const deepseek = missing ? /^deepseek/i.test(missing.route) : false; return (0, import_react15.createElement)( "div", { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 }, @@ -8601,7 +8607,8 @@ function TurnErrorCell({ node, t: chatT, actions }) { choices.length > 0 ? (0, import_react15.createElement)( "div", { className: "bt-turn-error-actions" }, - choices.map(([label, run]) => (0, import_react15.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)) + choices.map(([label, run]) => (0, import_react15.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), + deepseek ? (0, import_react15.createElement)(KeyHelpLink, { key: "key-help" }) : null ) : null ); } @@ -8634,7 +8641,10 @@ var MODEL_CSS = ` .bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135} .bt-turn-error-message{color:var(--bt-ink-2)} .bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} -.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;gap:6px} +.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px} +.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px} +.bt-model-help:hover{color:var(--bt-ink)} +.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px} .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 261f93d0..ea9f8251 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -243,6 +243,7 @@ const ZH = { "Hi, I'm {name}. Pick a model for me first. You can change it later in my details.": '你好,我是 {name}。先给我选一个模型吧,以后可以在我的详情里换。', 'Sign in to DeepSeek': '登录 DeepSeek 账号', 'Enter a DeepSeek API key': '填入 DeepSeek API key', + 'How to set a DeepSeek API key': '如何设置 DeepSeek API key', 'Open settings to set up a custom model': '打开设置配置自定义模型', 'FlyBot': 'FlyBot', 'FlyBot core': 'FlyBot 核心', diff --git a/core/src/client/model-menu.js b/core/src/client/model-menu.js index 5b2aa594..f3dd2eb2 100644 --- a/core/src/client/model-menu.js +++ b/core/src/client/model-menu.js @@ -221,7 +221,7 @@ export function ModelChoiceDock({ sessionId, useRoster, actions }) { ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }) const choices = models.length === 0 - ? setupChoices(actions).map(([label, run]) => h('button', { key: label, type: 'button', className: 'bt-model-chip', onClick: run }, label)) + ? [...setupChoices(actions).map(([label, run]) => h('button', { key: label, type: 'button', className: 'bt-model-chip', onClick: run }, label)), h(KeyHelpLink, { key: 'key-help' })] : [ ...groupModels(models).map(([provider, list]) => h('div', { key: list[0].provider ?? provider, className: 'bt-model-choice-group', role: 'group', 'aria-label': provider }, h('div', { className: 'bt-model-choice-provider', 'aria-hidden': true }, provider), @@ -263,6 +263,14 @@ function setupChoices(actions) { ].filter(Boolean) } +// How to set the key without the shell's dialog (#451): the dialog keeps only a typed or +// pasted value, so the hint under the DeepSeek choices links FlyBot's own guide. +export const KEY_HELP_URL = 'https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md' + +export function KeyHelpLink() { + return h('a', { className: 'bt-model-help', href: KEY_HELP_URL, target: '_blank', rel: 'noopener noreferrer' }, t('How to set a DeepSeek API key')) +} + // A run that failed because its model has no credential names the route DSH could // not reach (llm-deepseek: `no API key for provider route "deepseek-official"`). // `undefined` for any other failure; `{ route }` (route may be '') for a missing key. @@ -300,6 +308,7 @@ export function TurnErrorCell({ node, t: chatT, actions }) { : code === 'QUOTA' || code === 'ACCOUNT_QUOTA' ? shell('message.failure.quota', raw) : code === 'AUTH' ? shell('message.failure.auth', raw) : raw const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : [] + const deepseek = missing ? /^deepseek/i.test(missing.route) : false return h('div', { className: 'bt-turn-error', role: 'status', 'data-missing-key': missing ? '' : undefined }, h('span', { className: 'bt-turn-error-dot', 'aria-hidden': true }), h('div', { className: 'bt-turn-error-copy' }, @@ -307,7 +316,8 @@ export function TurnErrorCell({ node, t: chatT, actions }) { h('span', { className: 'bt-turn-error-message' }, message), code ? h('code', { className: 'bt-turn-error-code' }, code) : null), choices.length > 0 ? h('div', { className: 'bt-turn-error-actions' }, - choices.map(([label, run]) => h('button', { key: label, type: 'button', className: 'bt-model-chip', onClick: run }, label))) : null) + choices.map(([label, run]) => h('button', { key: label, type: 'button', className: 'bt-model-chip', onClick: run }, label)), + deepseek ? h(KeyHelpLink, { key: 'key-help' }) : null) : null) } // The shell's own DeepSeek sign-in dialog, borrowed from its account plugin: the @@ -340,7 +350,10 @@ export const MODEL_CSS = ` .bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135} .bt-turn-error-message{color:var(--bt-ink-2)} .bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} -.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;gap:6px} +.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px} +.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px} +.bt-model-help:hover{color:var(--bt-ink)} +.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px} .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} diff --git a/core/test/model-menu.test.js b/core/test/model-menu.test.js index 933404f1..1e2b640f 100644 --- a/core/test/model-menu.test.js +++ b/core/test/model-menu.test.js @@ -26,7 +26,7 @@ runInNewContext(bundle.outputFiles[0].text, { return platform[name] }, }) -const { groupModels, ModelChoiceDock, effortRows, TurnErrorCell, missingModelKey } = module.exports +const { groupModels, ModelChoiceDock, effortRows, TurnErrorCell, missingModelKey, KeyHelpLink, KEY_HELP_URL } = module.exports function nodes(tree, match) { if (!tree || typeof tree !== 'object') return [] @@ -89,6 +89,8 @@ test('an empty catalog offers setup choices, and a ready model needs no choice d assert.deepEqual(nodes(tree, node => node.type === 'button').map(button => button.children[0]), [ 'Enter a DeepSeek API key', 'Open settings to set up a custom model', ]) + // The shell's key dialog keeps only a typed or pasted key, so the guide is linked too (#451). + assert.equal(nodes(tree, node => node.type === KeyHelpLink).length, 1) assert.equal(dock(models, { model: models[0].ref }).tree, null) assert.equal(dock(models, { ready: false }).tree, null) }) @@ -125,6 +127,7 @@ test('a run that failed for a missing API key offers the model setup right under assert.doesNotMatch(text(tree), /DEEPSEEK_API_KEY|launching environment/) const labels = nodes(tree, node => node.type === 'button').map(button => button.children[0]) assert.deepEqual([...labels], ['Enter a DeepSeek API key', 'Open settings to set up a custom model']) + assert.equal(nodes(tree, node => node.type === KeyHelpLink).length, 1) // The raw provider text is never drawn, not even on hover (a provider may echo a key). assert.equal(nodes(tree, node => node.props.className === 'bt-turn-error-message')[0].props.title, undefined) const echoed = TurnErrorCell({ node: { data: { message: 'No API key found for sk-abc123', code: 'AUTH' } }, t: chatT, actions: {} }) @@ -132,6 +135,7 @@ test('a run that failed for a missing API key offers the model setup right under // Another route opens the model settings; any other failure reads as DSH writes it, no buttons. const other = TurnErrorCell({ node: { data: { message: 'no API key for provider route "openrouter"' } }, t: chatT, actions: {} }) assert.deepEqual([...nodes(other, node => node.type === 'button').map(button => button.children[0])], ['Manage models']) + assert.equal(nodes(other, node => node.type === KeyHelpLink).length, 0) // A provider that does not name its route is not assumed to be DeepSeek. const unnamed = TurnErrorCell({ node: { data: { message: 'API key not configured' } }, t: chatT, actions: {} }) assert.deepEqual([...nodes(unnamed, node => node.type === 'button').map(button => button.children[0])], ['Manage models']) @@ -142,3 +146,13 @@ test('a run that failed for a missing API key offers the model setup right under const plain = TurnErrorCell({ node: { data: { message: 'socket hang up' } }, t: chatT, actions: {} }) assert.match(text(plain), /socket hang up/) }) + +test('the DeepSeek key guide opens FlyBot\'s doc in a new tab (#451)', () => { + const link = KeyHelpLink() + assert.equal(link.type, 'a') + assert.equal(link.props.href, KEY_HELP_URL) + assert.match(KEY_HELP_URL, /^https:\/\/github\.com\/ThinkFlowLab\/FlyBot\/blob\/main\/docs\/deepseek-api-key\.md$/) + assert.equal(link.props.target, '_blank') + assert.equal(link.props.rel, 'noopener noreferrer') + assert.deepEqual([...link.children], ['How to set a DeepSeek API key']) +}) diff --git a/docs/deepseek-api-key.md b/docs/deepseek-api-key.md new file mode 100644 index 00000000..3667b886 --- /dev/null +++ b/docs/deepseek-api-key.md @@ -0,0 +1,58 @@ +# Set a DeepSeek API key + +A Bot whose model is on the DeepSeek API route (`deepseek-official`, for example **DeepSeek-V41-Flash**) needs a DeepSeek API key. Without one, its turn fails with **This Bot's model has no API key yet** (`MISSING_CREDENTIAL`). + +FlyBot does not keep the key. DeepSeek Harness (DSH) stores it and sends it with each model request, under the name `DEEPSEEK_API_KEY`. Get a key on the [DeepSeek platform](https://platform.deepseek.com/api_keys). On DSH Desktop, **Sign in to DeepSeek** is another way: it uses your DeepSeek account and needs no key. + +There are three ways to set the key. Pick one. + +## 1. In the app + +1. Under the error, click **Enter a DeepSeek API key** (a Bot with no model offers the same button). You can also open Settings → **Models** and set up DeepSeek there. +2. **Type or paste** the key into the **API key** field. +3. Click **Save and continue**, then send your message again. + +The dialog belongs to DSH. It keeps only what you type or paste: a value that a script, an accessibility tool or some password managers put into the field can be cleared when you click **Save and continue**, and nothing is saved ([#451](https://github.com/ThinkFlowLab/FlyBot/issues/451)). In that case paste the key by hand, or use way 2 or 3. + +## 2. In the credentials file + +The dialog saves the key to a file in your DSH home folder. You can write that file yourself, for example on a server or before the first start: + +- `~/.dsh/.credentials.yaml`, or `.credentials.yaml` in `DSH_HOME` when it is set +- Windows: `C:\Users\\.dsh\.credentials.yaml` + +```yaml +version: 1 +refs: + DEEPSEEK_API_KEY: sk-your-key +``` + +If the file already exists, add the `DEEPSEEK_API_KEY` line under `refs` and keep the other entries. Only you may read the file: on Linux and macOS run `chmod 600 ~/.dsh/.credentials.yaml`, because DSH refuses to load a file that other users can read. DSH also refuses a file with anything else at the top level. A running DSH picks up the change by itself; no restart is needed. + +## 3. In the environment + +Set `DEEPSEEK_API_KEY` in the shell that starts DSH: + +```sh +DEEPSEEK_API_KEY=sk-your-key npx -y @deepseek-ai/dsh@0.2.0-rc.2 web --port 3080 +``` + +DSH reads the environment once, at start, so a key exported later needs a restart. While it is set, it wins over every other place and DSH treats the key as read-only: saving a different key in the app is refused until you start DSH without the variable. + +A `.env` file with a `DEEPSEEK_API_KEY=sk-your-key` line also works, either in the folder you start DSH from or in your DSH home folder (`~/.dsh/.env`). A key saved in the app or in the credentials file wins over both. + +## Which key DSH uses + +The first place that has a key wins: + +| Place | Set by | +| --- | --- | +| The environment DSH was started in | `DEEPSEEK_API_KEY=… dsh …` | +| The credentials file | the app (way 1) or you (way 2) | +| `.env` in the folder you start DSH from | you | +| `.env` in your DSH home folder | you | + +## Keep the key safe + +- Never send the key to a Bot in a chat, and never commit a `.env` or `.credentials.yaml` file. +- Bots run as your own OS user, so a Bot that runs commands could read the credentials file. Use a key that you can revoke on the DeepSeek platform. diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 27e1a021..70193f2d 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -108,7 +108,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - The sidebar footer button (`ConnectApps` in `sidebar.js`, label **Connect apps** / 连接应用) opens Settings > Connectors through `actions.openConnectors()`, the same call and the same words as the account menu's **Connect apps** row. The shell's plugin panel stays reachable in the Bot surface through the account menu's **Plugins** row (`actions.selectPanel('plugins')`, 插件), without switching to classic Agent mode. - One name per destination in the account menu and command palette (#392, #393, #394): **Settings** opens the Settings dialog (General), since **Bot settings** / **Edit Bot** open one Bot; **Scheduled tasks** opens Settings → Scheduled tasks, the name Settings and a Bot's details use (DSH's own bundle stays "Automation tasks" in the Plugins hints); and **Switch to classic Agent mode** is the only way to classic Agent mode, so Settings → General is Appearance only. - Create Bot starts on the Main Bot's model (#230): `startModel()` (`bot-draft.js`) picks the Main Bot's chosen model, else the one it last ran on, when it is served; then `roster.defaultModel`, then the first served model. Only served models are picked, so a provider without a credential is never preselected. -- A missing model credential has its fix under the error (#405): `TurnErrorCell` (`model-menu.js`) shadows DSH's `turn-error` chat node (`locale: 'chat'`, so title and the AUTH / QUOTA / account copy are DSH's own strings). When the failure says the route has no API key (`missingModelKey`, e.g. `no API key for provider route "deepseek-official"`), the row says the Bot's model has no API key yet (the raw provider text is not drawn, not even on hover, since a provider may echo part of a key) and, for a `deepseek*` route, offers the same chips as a Bot with no model (`setupChoices`: Sign in to DeepSeek where the shell can, Enter a DeepSeek API key, Open settings to set up a custom model); any other or unnamed route gets Manage models. Every other failure reads as DSH writes it, with no buttons. +- A missing model credential has its fix under the error (#405): `TurnErrorCell` (`model-menu.js`) shadows DSH's `turn-error` chat node (`locale: 'chat'`, so title and the AUTH / QUOTA / account copy are DSH's own strings). When the failure says the route has no API key (`missingModelKey`, e.g. `no API key for provider route "deepseek-official"`), the row says the Bot's model has no API key yet (the raw provider text is not drawn, not even on hover, since a provider may echo part of a key) and, for a `deepseek*` route, offers the same chips as a Bot with no model (`setupChoices`: Sign in to DeepSeek where the shell can, Enter a DeepSeek API key, Open settings to set up a custom model); any other or unnamed route gets Manage models. Under the DeepSeek chips (here and in the no-model dock) a **How to set a DeepSeek API key** link (`KeyHelpLink`, new tab) opens [docs/deepseek-api-key.md](../deepseek-api-key.md) on GitHub: DSH's own key dialog keeps only a typed or pasted key (#451), so the guide also gives the credentials file and `DEEPSEEK_API_KEY` ways. Every other failure reads as DSH writes it, with no buttons. - **Models and providers…**, **Manage models** and **Open settings to set up a custom model** open DSH's Settings on its Models page (#407), not General: the `settings.launcher` seat's `openSettings()` takes no section, so `openHarnessModels()` (`sidebar.js`) calls the seat's `openOnboarding('flybot-models')`. That `settings.onboarding` step (`ModelsSettingsStep`, order 1000, after DSH's own steps) is handed `openSection`: opened on purpose (`explicit`) it calls `openSection('models')`, then `complete()`; in first-run onboarding it only completes. With no seat it falls back to `openHarnessSettings()`. FlyBot's own **Harness settings** entry still opens DSH settings as a whole. - Settings say each thing once (#230): the Model and Main Bot cards show no row label that repeats the card title (`SwitchRow` `showLabel: false` keeps the switch's accessible name), the Create Bot dialog has its Back / Close in the header only (the footer Back / Cancel is for the inline form), and the account menu is as wide as its longest item (`width:max-content`, capped to the viewport) instead of cutting `Switch to classic Agent mode`. - Slot `flybot.settings.page` (`0003-flybot-api-client`, types in `flybot.d.ts`, #337) adds a page to the Settings list right after Connectors: `{ id, order, label, icon?, component }`. `SettingsDialog` (`settings.js`) lists `useSettingsPages()` entries under the key `plugin:` and draws the component through `SettingsPageSlot` (a failing component draws nothing, it does not close the dialog); a page whose plugin is gone opens General. `label` is an English key for the host `t()`; the component gets `{ roster, actions, call, run, t, Section, Row }` (the host translator, the Settings section card and row, so the page matches the others and reads zh from `i18n.js`). Social registers **Bot Social** (the server, see [social](social.md)). Test: `core/test/settings-page-slot.test.js`. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 9606c9c3..7f8efe9f 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -403,7 +403,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Connected: '已连接', Disconnect: '断开', Connect: '连接', -@@ -105,17 +105,36 @@ const ZH = { +@@ -105,17 +105,37 @@ const ZH = { 'GitHub addresses must start with https://': 'GitHub 地址必须以 https:// 开头', 'This DSH cannot load connectors': '这个 DSH 不能加载连接器', 'No such connector': '没有这个连接器', @@ -437,6 +437,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js - 'Mode': '模式', 'Sign in to DeepSeek': '登录 DeepSeek 账号', 'Enter a DeepSeek API key': '填入 DeepSeek API key', ++ 'How to set a DeepSeek API key': '如何设置 DeepSeek API key', 'Open settings to set up a custom model': '打开设置配置自定义模型', - 'DS Bot': 'DS Bot', + 'FlyBot': 'FlyBot', @@ -773,6 +774,15 @@ diff --git a/src/client/model-menu.js b/src/client/model-menu.js const models = roster.models ?? [] const lost = bot.model ? !models.some(model => model.ref === bot.model) : false // An unknown catalog (none listed) cannot tell a lost model from a slow provider. +@@ -215,7 +215,7 @@ export function ModelChoiceDock({ sessionId, useRoster, actions }) { + ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) + : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }) + const choices = models.length === 0 +- ? setupChoices(actions).map(([label, run]) => h('button', { key: label, type: 'button', className: 'bt-model-chip', onClick: run }, label)) ++ ? [...setupChoices(actions).map(([label, run]) => h('button', { key: label, type: 'button', className: 'bt-model-chip', onClick: run }, label)), h(KeyHelpLink, { key: 'key-help' })] + : [ + ...groupModels(models).map(([provider, list]) => h('div', { key: list[0].provider ?? provider, className: 'bt-model-choice-group', role: 'group', 'aria-label': provider }, + h('div', { className: 'bt-model-choice-provider', 'aria-hidden': true }, provider), @@ -225,7 +225,7 @@ export function ModelChoiceDock({ sessionId, useRoster, actions }) { title: model.ref === roster.defaultModel ? `${model.ref} · ${t('Harness default')}` : model.ref, onClick: () => pick(model.ref), @@ -798,7 +808,7 @@ diff --git a/src/client/model-menu.js b/src/client/model-menu.js // Ways to get a first model. The DeepSeek sign-in exists only where the shell carries // its account plugin (the Desktop); the API key and custom models use the shell's // own editors. -@@ -244,10 +244,57 @@ function setupChoices(actions) { +@@ -244,10 +244,67 @@ function setupChoices(actions) { return [ actions.canSignIn?.() ? [t('Sign in to DeepSeek'), () => actions.signIn()] : null, [t('Enter a DeepSeek API key'), () => openHarnessOnboarding('deepseek-official')], @@ -807,6 +817,14 @@ diff --git a/src/client/model-menu.js b/src/client/model-menu.js ].filter(Boolean) } ++// How to set the key without the shell's dialog (#451): the dialog keeps only a typed or ++// pasted value, so the hint under the DeepSeek choices links FlyBot's own guide. ++export const KEY_HELP_URL = 'https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md' ++ ++export function KeyHelpLink() { ++ return h('a', { className: 'bt-model-help', href: KEY_HELP_URL, target: '_blank', rel: 'noopener noreferrer' }, t('How to set a DeepSeek API key')) ++} ++ +// A run that failed because its model has no credential names the route DSH could +// not reach (llm-deepseek: `no API key for provider route "deepseek-official"`). +// `undefined` for any other failure; `{ route }` (route may be '') for a missing key. @@ -844,6 +862,7 @@ diff --git a/src/client/model-menu.js b/src/client/model-menu.js + : code === 'QUOTA' || code === 'ACCOUNT_QUOTA' ? shell('message.failure.quota', raw) + : code === 'AUTH' ? shell('message.failure.auth', raw) : raw + const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : [] ++ const deepseek = missing ? /^deepseek/i.test(missing.route) : false + return h('div', { className: 'bt-turn-error', role: 'status', 'data-missing-key': missing ? '' : undefined }, + h('span', { className: 'bt-turn-error-dot', 'aria-hidden': true }), + h('div', { className: 'bt-turn-error-copy' }, @@ -851,13 +870,14 @@ diff --git a/src/client/model-menu.js b/src/client/model-menu.js + h('span', { className: 'bt-turn-error-message' }, message), + code ? h('code', { className: 'bt-turn-error-code' }, code) : null), + choices.length > 0 ? h('div', { className: 'bt-turn-error-actions' }, -+ choices.map(([label, run]) => h('button', { key: label, type: 'button', className: 'bt-model-chip', onClick: run }, label))) : null) ++ choices.map(([label, run]) => h('button', { key: label, type: 'button', className: 'bt-model-chip', onClick: run }, label)), ++ deepseek ? h(KeyHelpLink, { key: 'key-help' }) : null) : null) +} + // The shell's own DeepSeek sign-in dialog, borrowed from its account plugin: the // plugin's model sign-in entry renders here with the plugin's own operations. export function DeepSeekSignIn({ useUi, actions }) { -@@ -272,6 +272,13 @@ function SignInHost({ entry, actions }) { +@@ -272,6 +272,16 @@ function SignInHost({ entry, actions }) { } export const MODEL_CSS = ` @@ -867,7 +887,10 @@ diff --git a/src/client/model-menu.js b/src/client/model-menu.js +.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135} +.bt-turn-error-message{color:var(--bt-ink-2)} +.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} -+.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;gap:6px} ++.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px} ++.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px} ++.bt-model-help:hover{color:var(--bt-ink)} ++.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px} .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} @@ -3200,11 +3223,20 @@ diff --git a/test/model-menu.test.js b/test/model-menu.test.js }, }) -const { groupModels, ModelChoiceDock, effortRows } = module.exports -+const { groupModels, ModelChoiceDock, effortRows, TurnErrorCell, missingModelKey } = module.exports ++const { groupModels, ModelChoiceDock, effortRows, TurnErrorCell, missingModelKey, KeyHelpLink, KEY_HELP_URL } = module.exports function nodes(tree, match) { if (!tree || typeof tree !== 'object') return [] -@@ -99,3 +99,46 @@ test('the Thinking section always offers Default, so a picked level can be clear +@@ -89,6 +89,8 @@ test('an empty catalog offers setup choices, and a ready model needs no choice d + assert.deepEqual(nodes(tree, node => node.type === 'button').map(button => button.children[0]), [ + 'Enter a DeepSeek API key', 'Open settings to set up a custom model', + ]) ++ // The shell's key dialog keeps only a typed or pasted key, so the guide is linked too (#451). ++ assert.equal(nodes(tree, node => node.type === KeyHelpLink).length, 1) + assert.equal(dock(models, { model: models[0].ref }).tree, null) + assert.equal(dock(models, { ready: false }).tree, null) + }) +@@ -99,3 +99,58 @@ test('the Thinking section always offers Default, so a picked level can be clear assert.deepEqual([...effortRows({ efforts }).map(row => row.effort)], [undefined, 'low', 'high']) assert.equal(effortRows({ efforts: [] }).length, 0) }) @@ -3234,6 +3266,7 @@ diff --git a/test/model-menu.test.js b/test/model-menu.test.js + assert.doesNotMatch(text(tree), /DEEPSEEK_API_KEY|launching environment/) + const labels = nodes(tree, node => node.type === 'button').map(button => button.children[0]) + assert.deepEqual([...labels], ['Enter a DeepSeek API key', 'Open settings to set up a custom model']) ++ assert.equal(nodes(tree, node => node.type === KeyHelpLink).length, 1) + // The raw provider text is never drawn, not even on hover (a provider may echo a key). + assert.equal(nodes(tree, node => node.props.className === 'bt-turn-error-message')[0].props.title, undefined) + const echoed = TurnErrorCell({ node: { data: { message: 'No API key found for sk-abc123', code: 'AUTH' } }, t: chatT, actions: {} }) @@ -3241,6 +3274,7 @@ diff --git a/test/model-menu.test.js b/test/model-menu.test.js + // Another route opens the model settings; any other failure reads as DSH writes it, no buttons. + const other = TurnErrorCell({ node: { data: { message: 'no API key for provider route "openrouter"' } }, t: chatT, actions: {} }) + assert.deepEqual([...nodes(other, node => node.type === 'button').map(button => button.children[0])], ['Manage models']) ++ assert.equal(nodes(other, node => node.type === KeyHelpLink).length, 0) + // A provider that does not name its route is not assumed to be DeepSeek. + const unnamed = TurnErrorCell({ node: { data: { message: 'API key not configured' } }, t: chatT, actions: {} }) + assert.deepEqual([...nodes(unnamed, node => node.type === 'button').map(button => button.children[0])], ['Manage models']) @@ -3251,6 +3285,16 @@ diff --git a/test/model-menu.test.js b/test/model-menu.test.js + const plain = TurnErrorCell({ node: { data: { message: 'socket hang up' } }, t: chatT, actions: {} }) + assert.match(text(plain), /socket hang up/) +}) ++ ++test('the DeepSeek key guide opens FlyBot\'s doc in a new tab (#451)', () => { ++ const link = KeyHelpLink() ++ assert.equal(link.type, 'a') ++ assert.equal(link.props.href, KEY_HELP_URL) ++ assert.match(KEY_HELP_URL, /^https:\/\/github\.com\/ThinkFlowLab\/FlyBot\/blob\/main\/docs\/deepseek-api-key\.md$/) ++ assert.equal(link.props.target, '_blank') ++ assert.equal(link.props.rel, 'noopener noreferrer') ++ assert.deepEqual([...link.children], ['How to set a DeepSeek API key']) ++}) diff --git a/test/models.test.js b/test/models.test.js --- a/test/models.test.js +++ b/test/models.test.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 91577e54..a183c429 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -160,7 +160,7 @@ diff --git a/client.js b/client.js Connected: "已连接", Disconnect: "断开", Connect: "连接", -@@ -141,17 +141,36 @@ var ZH = { +@@ -141,17 +141,37 @@ var ZH = { "GitHub addresses must start with https://": "GitHub 地址必须以 https:// 开头", "This DSH cannot load connectors": "这个 DSH 不能加载连接器", "No such connector": "没有这个连接器", @@ -194,6 +194,7 @@ diff --git a/client.js b/client.js - "Mode": "模式", "Sign in to DeepSeek": "登录 DeepSeek 账号", "Enter a DeepSeek API key": "填入 DeepSeek API key", ++ "How to set a DeepSeek API key": "如何设置 DeepSeek API key", "Open settings to set up a custom model": "打开设置配置自定义模型", - "DS Bot": "DS Bot", + "FlyBot": "FlyBot", @@ -6374,7 +6375,7 @@ diff --git a/client.js b/client.js const text = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); - const choices = models.length === 0 ? setupChoices(actions).map(([label, run]) => (0, import_react13.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)) : [ - ...groupModels(models).map(([provider, list]) => (0, import_react13.createElement)( -+ const choices = models.length === 0 ? setupChoices(actions).map(([label, run]) => (0, import_react15.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)) : [ ++ const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react15.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react15.createElement)(KeyHelpLink, { key: "key-help" })] : [ + ...groupModels(models).map(([provider, list]) => (0, import_react15.createElement)( "div", { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider }, @@ -6385,7 +6386,7 @@ diff --git a/client.js b/client.js key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,50 +6584,109 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6584,50 +6584,115 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) @@ -6452,6 +6453,10 @@ diff --git a/client.js b/client.js + [t("Open settings to set up a custom model"), openHarnessModels] ].filter(Boolean); } ++var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md"; ++function KeyHelpLink() { ++ return (0, import_react15.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); ++} +function missingModelKey(message) { + const text = String(message ?? ""); + if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text)) return void 0; @@ -6473,6 +6478,7 @@ diff --git a/client.js b/client.js + const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed")); + const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw; + const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : []; ++ const deepseek = missing ? /^deepseek/i.test(missing.route) : false; + return (0, import_react15.createElement)( + "div", + { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 }, @@ -6487,7 +6493,8 @@ diff --git a/client.js b/client.js + choices.length > 0 ? (0, import_react15.createElement)( + "div", + { className: "bt-turn-error-actions" }, -+ choices.map(([label, run]) => (0, import_react15.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)) ++ choices.map(([label, run]) => (0, import_react15.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), ++ deepseek ? (0, import_react15.createElement)(KeyHelpLink, { key: "key-help" }) : null + ) : null + ); +} @@ -6511,7 +6518,7 @@ diff --git a/client.js b/client.js ...ops, ...hooks, t: actions.accountT(), -@@ -6639,6 +6639,13 @@ function SignInHost({ entry, actions }) { +@@ -6639,6 +6639,16 @@ function SignInHost({ entry, actions }) { }); } var MODEL_CSS = ` @@ -6521,7 +6528,10 @@ diff --git a/client.js b/client.js +.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135} +.bt-turn-error-message{color:var(--bt-ink-2)} +.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} -+.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;gap:6px} ++.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px} ++.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px} ++.bt-model-help:hover{color:var(--bt-ink)} ++.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px} .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px}