diff --git a/apps/aevatar-console-web/docs/2026-09-15-channel-runtime-editor.md b/apps/aevatar-console-web/docs/2026-09-15-channel-runtime-editor.md index a386bf48d..0796c17af 100644 --- a/apps/aevatar-console-web/docs/2026-09-15-channel-runtime-editor.md +++ b/apps/aevatar-console-web/docs/2026-09-15-channel-runtime-editor.md @@ -5,40 +5,53 @@ Channel details now provides an Edit action at Edit and Remove sit at the top right beside the breadcrumb, with a 12px gap. On narrow screens, the action group wraps below the breadcrumb and stays right aligned. The editor follows the approved [Figma frame](https://www.figma.com/design/FaVJx5IZeQX9jHcUi55ndB/?node-id=63-327) -and the existing Telegram connection form. +and the existing Telegram connection form, with the September 16 request +to limit editing to Label and Skill name. ## Shared Components - `ChannelSkillField` is shared by connection and editing, including its label, optional state, input, and field error presentation. -- Both pages use `ChannelServicePicker`, `useChannelServiceChoices`, the existing - connection styles, shell, loading skeleton, and toast provider. -- The form contains only Skill name and Services, with no Advanced settings - section or inputs for Skill version and Bot instructions. -- Channel names and bot tokens are not editable through this API. Tool sets, - extra tool names, instructions, skill version, and credential source are - retained from the loaded config. +- Both pages reuse the existing connection styles, centered content, shell, + loading skeleton, and toast provider. +- The edit form contains only Label and Skill name. It does not load the + UserService inventory or display a Services selector. Creation retains its + existing service selection; details retain their read-only authorization list. +- Bot tokens are not editable. Tool sets, extra tool names, instructions, + service selectors, skill version, and credential source are retained from the + loaded config (clearing Skill name also clears its version). ## API Behavior -The API contract was checked against `origin/feature/integrate` at -`6c929a00db3d7636c91bc719478043a5fd331f7c`, specifically -`ChannelCallbackEndpoints.cs` and `ChannelRegistrationServiceSelection.cs`. +The runtime-config contract was checked against `origin/feature/integrate` at +`6c929a00db3d7636c91bc719478043a5fd331f7c`, specifically `ChannelCallbackEndpoints.cs`. Label updates use NyxID's existing +`backend/src/handlers/channel_bots.rs` contract; no backend changes are required. - GET and POST use `/api/channels/registrations/{registrationId}/runtime-config`. Detail identity and scope must match the route. -- Opening the editor requires a fresh successful GET, including when Query has - cached data from an earlier visit. Subsequent readback never resets user input. -- POST sends the complete known runtime config because omitted fields are not - patches. Strings edited by the user are trimmed as the backend parser expects. -- Clearing Skill name also clears its version. Removing service authorization - removes selectors for the deselected service, preserving other selectors. -- Services use the same authenticated inventory and bearer grant filtering as - creation. Saved services absent from current choices remain visible as - unavailable until deliberately removed. They are never silently dropped. -- Existing `nyxid_default` registrations retain their mode until the user - explicitly switches to individual selection. Zero individually selected - services is sent as an explicit empty allowlist. +- Opening the editor requires fresh successful config, registration-list, and + personal NyxID bot-list reads, even when Query has cached data. The exact owned + registration's `nyx_channel_bot_id` and matching platform identify the bot. + Missing/cross-scope identities block the editor; registration IDs never serve + as bot IDs. Later readback never resets user input. +- Label uses the actual NyxID bot label, not the runtime-config `label` placeholder + (currently the registration ID). Only a changed label triggers + `PATCH /api/v1/channel-bots/{botId}` with `{ "label": "..." }`. NyxID requires a + non-empty trimmed label of at most 128 UTF-8 bytes. The PATCH response must + confirm the exact bot ID, platform, and label before the safe query cache is + updated; credential fields and raw diagnostic bodies are discarded. +- A changed Skill name submits the complete known runtime config because omitted + runtime fields are not patches. Only Skill name is trimmed; other fields are + retained. The request omits `authorization_mode` and `service_ids`, allowing + the backend to preserve its current service selection, including legacy + defaults and explicit empty allowlists. Backend authorization checks still + apply; there is no frontend authorization override. +- When both fields change, Label saves first, then Skill name. A rejected Label + request leaves both inputs intact and skips the runtime update. If Label has + saved but Skill fails, the editor explicitly reports partial success and a + retry submits only the still-unsaved changes. The two APIs are not atomic. +- A Label-only save returns to details after the confirmed PATCH without + submitting runtime config or performing an unnecessary config readback. - After an acknowledged POST, the form makes one GET for accurate feedback and returns to channel details. The Save action remains busy across both requests and rejects duplicate submission. There is no separate confirmation step, @@ -75,18 +88,17 @@ The API contract was checked against `origin/feature/integrate` at Focused integration cases cover exact identity, safe decoding, unsupported config, detail-to-edit navigation, whole-config preservation, accepted readback, -explicit clearing, unavailable services, field errors, cached detail revalidation, +explicit clearing, label validation and exact bot identity, partial-save retry, +field errors, cached detail revalidation, legacy authorization, delayed/failed readback navigation, duplicate-submit protection, failed-save retry, unsaved navigation, and unmounts during POST/GET. Existing creation, channel listing/details, API, navigation, route configuration, and locale tests protect the reused surfaces. The original editor was verified against the configured remote backend for -configuration prefill, service selection, and desktop and 390px layouts. -The save-flow revision uses the focused integration cases above; the local -preview compiles on port 5173, its API proxy responds, and the browser shows the -login page. Its OAuth callback uses the same origin. No live bot configuration -was changed during verification. +configuration prefill and desktop and 390px layouts. The names-only revision is +covered by API-boundary integration tests. No live bot configuration is changed +for verification. Local verification is restricted to affected Jest files, changed-file Biome, the test stability guard, baseline integrity, and diff checks. Full frontend diff --git a/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md b/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md index 1acceaf47..ea448aa27 100644 --- a/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md +++ b/apps/aevatar-console-web/docs/features/2026-09-14-channel-pages.md @@ -4,9 +4,11 @@ Channels is a first-level destination in the Workflow Activity vNext sidebar. It lets the signed-in owner view their connected bots and inspect a connection. The three pages follow the latest simplified [Figma design](https://www.figma.com/design/FaVJx5IZeQX9jHcUi55ndB?node-id=7-226) -(list frame `7:2`, detail frame `6:131`, Telegram form `7:226`). The current design intentionally keeps -channel details read-only with Remove as the sole resource action. The earlier -runtime-configuration editor is outside this iteration of issue #3617. +(list frame `7:2`, detail frame `6:131`, Telegram form `7:226`). The initial detail +design used Remove as its sole resource action. The current detail page also +links to the editor documented in +[Channel Runtime Editor](../2026-09-15-channel-runtime-editor.md); its edit form +now exposes only Label and Skill name, preserving existing service authorization. Channel refresh is explicitly user driven. Do not add background polling or refresh on focus/reconnection to these pages. diff --git a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts index 2a360d2a4..e660df8f4 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -1,4 +1,11 @@ export default { + 'channels.edit.label': 'Label', + 'channels.edit.labelError': + 'Enter a non-empty label. If it is too long, shorten it and try again.', + 'channels.edit.labelFailed': + 'Could not save the label. Check it and try again.', + 'channels.edit.partialSave': + 'Label saved, but the skill name could not be updated. Try saving again.', 'channels.edit': 'Edit', 'channels.edit.title': 'Edit {platform}', 'channels.edit.loadingTitle': 'Edit channel', diff --git a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts index d97beb847..67c28c2ca 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -1,4 +1,9 @@ export default { + 'channels.edit.label': 'Label', + 'channels.edit.labelError': 'Label 不能为空;如果名称过长,请缩短后重试。', + 'channels.edit.labelFailed': '无法保存 Label,请检查后重试。', + 'channels.edit.partialSave': + 'Label 已保存,但 Skill 名称更新失败,请再次保存。', 'channels.edit': '编辑', 'channels.edit.title': '编辑 {platform}', 'channels.edit.loadingTitle': '编辑渠道', diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDetailsPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDetailsPage.tsx index da28c8874..fb526693e 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDetailsPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDetailsPage.tsx @@ -5,12 +5,13 @@ import { ExportOutlined, ReloadOutlined, } from '@ant-design/icons'; -import { Button, Modal, Tooltip } from 'antd'; +import { Button, Modal } from 'antd'; import * as React from 'react'; import { channelsApi } from '@/shared/api/channelsApi'; import { t } from '@/shared/i18n/messages'; import { history } from '@/shared/navigation/history'; import { AevatarContentSkeleton } from '@/shared/ui/AevatarContentSkeleton'; +import AevatarTooltip from '@/shared/ui/AevatarTooltip'; import { useConsoleToast } from '@/shared/ui/ConsoleToast'; import { buildChannelEditHref, @@ -206,7 +207,7 @@ export default function ChannelDetailsPage({ ? t('channels.name.loading', 'Loading name…') : t('channels.name.unavailable', 'Name unavailable'))} {registration.botId && !channelName && !bots.isPending ? ( - diff --git a/apps/aevatar-console-web/src/shared/api/channelBotsApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelBotsApi.test.ts index bdc65936f..cced05ca2 100644 --- a/apps/aevatar-console-web/src/shared/api/channelBotsApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelBotsApi.test.ts @@ -1,5 +1,8 @@ import { authFetch } from '@/shared/auth/fetch'; -import { listChannelBotIdentities } from './channelBotsApi'; +import { + listChannelBotIdentities, + updateChannelBotLabel, +} from './channelBotsApi'; jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); jest.mock('@/shared/auth/config', () => ({ @@ -56,3 +59,61 @@ it('rejects ambiguous identities and HTTP failures without retaining error bodie await expect(listChannelBotIdentities()).rejects.toThrow('403'); expect(json).not.toHaveBeenCalled(); }); + +it('PATCHes only a trimmed label with an encoded bot ID and retains only verified identity fields', async () => { + const bot = { id: 'bot/a', platform: 'telegram', label: 'Original label' }; + fetchMock.mockResolvedValue( + response({ + ...bot, + label: 'Updated label', + webhook_secret: 'TEST_ONLY_SECRET', + }), + ); + expect(await updateChannelBotLabel(bot, ' Updated label ')).toEqual({ + ...bot, + label: 'Updated label', + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://nyx.example.test/api/v1/channel-bots/bot%2Fa', + { + method: 'PATCH', + credentials: 'omit', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ label: 'Updated label' }), + }, + ); +}); + +it.each([ + { id: 'other-bot', platform: 'telegram', label: 'Updated' }, + { id: 'bot-alpha', platform: 'lark', label: 'Updated' }, + { id: 'bot-alpha', platform: 'telegram', label: 'Original' }, +])('rejects mismatched label update acknowledgements: %j', async (result) => { + fetchMock.mockResolvedValue(response(result)); + await expect( + updateChannelBotLabel( + { id: 'bot-alpha', platform: 'telegram', label: 'Original' }, + 'Updated', + ), + ).rejects.toThrow('not confirmed'); +}); + +it('does not retain upstream label error bodies', async () => { + const json = jest.fn().mockResolvedValue({ token: 'TEST_ONLY_SECRET' }); + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json, + } as unknown as Response); + await expect( + updateChannelBotLabel( + { id: 'bot-alpha', platform: 'telegram', label: 'Original' }, + 'Updated', + ), + ).rejects.toThrow('403'); + expect(json).not.toHaveBeenCalled(); +}); diff --git a/apps/aevatar-console-web/src/shared/api/channelBotsApi.ts b/apps/aevatar-console-web/src/shared/api/channelBotsApi.ts index 5440db001..b2136793f 100644 --- a/apps/aevatar-console-web/src/shared/api/channelBotsApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelBotsApi.ts @@ -24,20 +24,62 @@ export async function listChannelBotIdentities( }); if (!response.ok) throw new ChannelApiError(response.status); const body = expectRecord(await response.json(), 'Channel bots'); - const bots = expectArray(body.bots, 'Channel bots', (value) => { - const bot = expectRecord(value, 'Channel bot'); - const id = readString(bot, 'id', 'Channel bot ID'); - const platform = readString(bot, 'platform', 'Channel bot platform'); - if (!id.trim() || !platform.trim()) - throw new Error('Missing channel bot identity.'); - // Keep only safe identity fields, never the full upstream object. - return { - id, - platform, - label: readString(bot, 'label', 'Channel bot label').trim() || null, - }; - }); + const bots = expectArray(body.bots, 'Channel bots', decodeBotIdentity); if (new Set(bots.map((bot) => bot.id)).size !== bots.length) throw new Error('Ambiguous channel bot identity.'); return bots; } + +function decodeBotIdentity(value: unknown): ChannelBotIdentity { + const bot = expectRecord(value, 'Channel bot'); + const id = readString(bot, 'id', 'Channel bot ID'); + const platform = readString(bot, 'platform', 'Channel bot platform'); + if (!id.trim() || !platform.trim()) + throw new Error('Missing channel bot identity.'); + // Keep only safe identity fields, never the full upstream object. + return { + id, + platform, + label: readString(bot, 'label', 'Channel bot label').trim() || null, + }; +} + +export function isValidChannelBotLabel(label: string): boolean { + const value = label.trim(); + // NyxID validates the trimmed UTF-8 byte length, not JavaScript code units. + return Boolean(value) && new TextEncoder().encode(value).length <= 128; +} + +export async function updateChannelBotLabel( + bot: ChannelBotIdentity, + label: string, +): Promise { + const config = getNyxIDRuntimeConfig(); + if (config.configurationError || !config.baseUrl) + throw new Error('NyxID is unavailable.'); + const value = label.trim(); + if (!bot.id.trim() || !isValidChannelBotLabel(value)) + throw new Error('Invalid channel bot label.'); + const response = await authFetch( + `${config.baseUrl}/api/v1/channel-bots/${encodeURIComponent(bot.id)}`, + { + method: 'PATCH', + credentials: 'omit', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ label: value }), + }, + ); + if (!response.ok) throw new ChannelApiError(response.status); + const updated = decodeBotIdentity(await response.json()); + if ( + updated.id !== bot.id || + updated.platform !== bot.platform || + updated.label !== value + ) + throw new Error('Channel label update was not confirmed.'); + return updated; +} diff --git a/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.test.ts index 94e534a58..55b0d6d51 100644 --- a/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.test.ts @@ -68,6 +68,6 @@ it('rejects an unrecognized config or receipt instead of replacing it with defau }), ); await expect( - channelRuntimeConfigApi.update('registration-alpha', detail), + channelRuntimeConfigApi.update('registration-alpha', detail.runtimeConfig), ).rejects.toThrow('not acknowledged'); }); diff --git a/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.ts b/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.ts index d073a0545..1c64721e8 100644 --- a/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelRuntimeConfigApi.ts @@ -132,20 +132,24 @@ export function channelConfigPayload(input: ChannelConfigUpdate) { return { authorization_mode: input.authorizationMode, service_ids: [...input.serviceIds].sort(), - runtime_config: { - instructions: config.instructions.trim(), - default_skill: { - name: config.defaultSkill.name.trim(), - version: config.defaultSkill.version.trim(), - }, - tool_set_refs: [...config.toolSetRefs], - extra_tool_names: [...config.extraToolNames], - nyxid_service_selectors: config.serviceSelectors.map((selector) => ({ - service_slug: selector.serviceSlug, - endpoint_names: [...selector.endpointNames], - })), - credential_source_mode: config.credentialSourceMode, + runtime_config: runtimeConfigPayload(config), + }; +} + +function runtimeConfigPayload(config: ChannelRuntimeConfig) { + return { + instructions: config.instructions, + default_skill: { + name: config.defaultSkill.name.trim(), + version: config.defaultSkill.version, }, + tool_set_refs: [...config.toolSetRefs], + extra_tool_names: [...config.extraToolNames], + nyxid_service_selectors: config.serviceSelectors.map((selector) => ({ + service_slug: selector.serviceSlug, + endpoint_names: [...selector.endpointNames], + })), + credential_source_mode: config.credentialSourceMode, }; } @@ -212,7 +216,7 @@ export const channelRuntimeConfigApi = { }, async update( registrationId: string, - input: ChannelConfigUpdate, + input: ChannelRuntimeConfig, ): Promise { const response = await authFetch( `${registrationPath(registrationId)}/runtime-config`, @@ -222,7 +226,8 @@ export const channelRuntimeConfigApi = { Accept: 'application/json', 'Content-Type': 'application/json', }, - body: JSON.stringify(channelConfigPayload(input)), + // Omitting service selection preserves the current backend authorization. + body: JSON.stringify({ runtime_config: runtimeConfigPayload(input) }), }, ); const value: unknown = await response.json().catch(() => null);