diff --git a/.project-docs/30-worklog/tasks/20260913-user-channels-ml-8d61e4a9.md b/.project-docs/30-worklog/tasks/20260913-user-channels-ml-8d61e4a9.md new file mode 100644 index 0000000..bb09c5f --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260913-user-channels-ml-8d61e4a9.md @@ -0,0 +1,59 @@ +# Task: 新增渠道菜单及多微信账号管理 + +## Identity + +- Task ID: 20260913-user-channels-ml-8d61e4a9 +- Mode: Feature +- Branch: codex/20260913-user-channels-ml-8d61e4a9-user-channels +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260913-user-channels-ml-8d61e4a9 +- Base commit: 46e7f6f1cd37124493be48bbb85f6a6d1de59c17 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement the MakeLore desktop user-level Channels navigation and WeChat account workspace over the agreed user-level ChannelAccount contract. +- Support multiple independently connected accounts, including unassigned connection, independent target selection, multiple accounts routed to one published Agent, target switching without QR, and explicit enable/pause/reconnect/disconnect controls. +- Replace embedded Agent publication management with linked-channel summaries and a shortcut while keeping existing invitation, permission, activity, conversation, notification and recovery flows reachable from the selected account detail. +- Own only this isolated client worktree's Renderer/Main typed client surface, focused tests, Electron E2E, README and this task record. No canonical project-memory writes, commit, merge, deploy or cleanup. + +## Intent And Constraints + +- Concurrent Task Gate passed: official check/status match task `20260913-user-channels-ml-8d61e4a9`, feature mode, branch and managed worktree, with base `46e7f6f1cd37124493be48bbb85f6a6d1de59c17` and owner `codex`. +- Planning Gate passed after reading the project-memory entry, active record, integrated state, decisions, architecture/data flow, domain/success criteria, evidence/reflection/commitment/stale indexes, accepted personal cloud Agent ADR and all other planning peer scopes. The related target-picker task is read-only; old placeholder tasks are unknown coordination state without a concrete semantic dependency. No semantic conflict blocks this isolated worktree. +- Product facts: account connection lifecycle is independent from Agent publication; target Agent is optional; route switching must preserve the connection and need no QR; account list comes from the user-level API rather than reconstructing accounts from Agent lists. Existing billing identity, Main Host API/recovery and QR challenge behavior remain. +- Agreed contract: user-level `/api/makelore/channel-accounts`, per-account `/route` and `/wechat-bind/*`, plus operation lookup by operation ID. Existing per-Agent detail endpoints remain compatibility/detail paths. Reused details must bind to the explicitly selected account ID/binding ID. +- Keep the native Electron light UI and current components; do not introduce a new design system or foreground backend pairing complexity. Creator self-code remains removed. Audience behavior remains unchanged. +- Planned validation is limited to checks that can change the implementation: focused unit behavior, TypeScript, scoped lint, Vite build and the actual Electron channel fixture with screenshot inspection. + +## Outcome + +- Added a user-level `渠道 > 微信` workspace that reads ChannelAccounts from the agreed user-level API, supports adding an account without an Agent, account-specific QR/verification, target assignment and switching, enable/pause/reconnect/disconnect, and durable operation recovery. +- Added typed Renderer/Main channel-account operations and safe DTO projection. Mutations use account-scoped routes, operation IDs and expected revisions; verification recovery uses the user-level operation lookup and keeps verification secrets out of the journal. +- Preserved account identity while routing: multiple accounts may target the same published Agent, an enabled account can switch targets without starting QR again, and clearing a target leaves the route disabled. +- Replaced the Agent Publish page's embedded management panel with linked-account summaries and shortcuts into the channel workspace. A shortcut can select an exact account or preselect the Agent for a new/unassigned account. +- Reused the existing access, activity and personal-conversation detail flows below the selected account. The embedded form is scoped to the exact account ID/binding ID and starts directly at its tabs, without the old publication heading, setup step numbering or duplicate connection status controls. +- Updated the Electron fixture for a legacy connected account plus an unassigned second account and captured the list, QR/target, Agent shortcut and compact account-detail states at 980x680. + +## Verification + +- `corepack pnpm --version`: passed with the required `10.33.4`. +- `corepack pnpm install --frozen-lockfile`: passed. +- Focused Vitest suites covering the account workspace, Main operation boundary, existing channel panel/recovery and Agent Publish integration: passed (the broad focused run reported 85 tests; the final Main rerun reported 25/25 after its added recovery case). +- `corepack pnpm typecheck`: passed after the final UI simplification. +- Scoped ESLint over the changed TypeScript/TSX files: passed after the final UI simplification. +- `corepack pnpm run build:vite`: passed for Renderer, Main, Preload and worker targets; Vite reported only its existing chunk-size warnings. +- Actual Electron fixture `tests/e2e/cloud-agent-channels.spec.ts`: passed 1/1 after the final UI simplification, including two accounts, unassigned scan, same-Agent routing, enabled target switching without another QR start, legacy account visibility, explicit account detail and small-window overflow. +- Inspected the resulting screenshots under `test-results/cloud-agent-channels-own-W-43513-ry-in-the-desktop-workspace/`; the final list shows the `渠道` eyebrow and `微信` title, compact account controls and secondary details without the duplicated legacy panel chrome. +- `git diff --check`: passed. +- Independent Reviewer: Standards PASS and Spec PASS with no MakeLore source findings; its independent focused/Main run passed 28 tests and its diff check passed. + +## Follow-ups + +- Integration must land with the coordinated AgentBus Core and Yuxi user-level ChannelAccount APIs; this client fixture does not verify a live WeChat login, production backend, deployment or packaged installer. +- The legacy per-Agent endpoints remain intentionally used only for account-scoped detail data until the backend exposes equivalent user-level detail endpoints. Every reused detail request is locked to the selected account ID/binding ID. +- No commit, merge, deployment or worktree cleanup was performed, per task ownership instructions. + +## Promotion Candidates + +- Target `.project-docs/20-current/current-state.md`: record that desktop channel lifecycle is user/account-owned and Agent publication is an optional route; evidence is the coordinated Core/Yuxi contract plus the focused unit and Electron fixture above. Future work should enter through the user-level account list instead of reconstructing accounts from Agent bindings. This may conflict with older language that treats WeChat setup as part of Agent publication, so integration should reconcile it with the accepted personal cloud Agent ADR. Human confirmation is not required beyond the owning integration task's normal semantic review. diff --git a/README.md b/README.md index 1e29689..10bd15a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 云知识库的向量模型由平台管理员配置:Yuxi 后台「智能体管理 → 模型供应商」启用 embedding 模型并填写实际维度;Works Square 运营后台「模型管理」从 one-api 刷新并激活相同模型 ID。one-api 必须支持该模型的向量接口。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - 在「发布与访问」中可通过 AgentBus 连接个人微信:选用本人已有连接或扫码连接新微信,设置联系人使用权限并确认启用,绑定时扫码的微信可直接发送消息,无需本人配对码。其他联系人仍需创建者邀请。渠道支持暂停、切换智能体、断开连接和查看运行/投递活动;失败的文件可单独补发。本人的微信会话可在客户端查看、审批、停止、新开一段并保存实际产物;受邀联系人的内容不会因创建者付费而对创建者开放。自动任务可明确选择把结果发送到已经开始对话的本人微信,暂停保留目标;费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录。 + Agents 侧栏的「渠道」统一管理个人微信账号。每个账号独立连接,可以在发布智能体前先扫码,之后选择或更换目标智能体;多个微信账号可以使用同一个已发布智能体,切换目标不需要重新扫码。账号可单独启用、暂停、重新连接或断开。Agent 的「发布与访问」只展示已关联渠道并提供定位入口。选中账号的二级详情保留联系人邀请与撤销、运行/投递活动、失败文件补发和本人微信会话;本人会话可审批、停止、新开一段并保存实际产物,受邀联系人的内容不会因创建者付费而对创建者开放。绑定时扫码的微信可直接发送消息,无需本人配对码,其他联系人仍需创建者邀请。自动任务可明确选择把结果发送到已经开始对话的本人微信,暂停保留目标;费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index afa2013..fa0194b 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -16,7 +16,7 @@ const mcpOutput = ['slug', 'name', 'description', 'transport', 'url', 'enabled', const child = ['name', 'purpose', 'system_prompt']; const channelView = ['id', 'address', 'display_name', 'target_agent_address', 'route_revision', 'provider_generation', 'binding_id', 'enabled', 'status', 'worker_online', 'agent_slug', 'published_version', 'desired_state', 'sync_state', 'health', 'last_confirmed_at', 'revision', 'blockers', 'access_mode', 'policy_revision', - 'adoption_state', 'managed_agent_slug']; + 'adoption_state', 'managed_agent_slug', 'target_agent_slug', 'target_agent_name', 'target_agent_published_version']; const channelSession = ['session_id', 'caller_id', 'agent_slug', 'agent_name', 'binding_id', 'provider_generation', 'channel_account_id', 'core_conversation_id', 'access_mode', 'grant_state', 'session_state', 'sequence', 'content_uid', 'thread_id', 'created_at', 'closed_at']; const channelCaller = ['caller_id', 'binding_id', 'provider_generation', 'channel_account_id', 'agent_slug', 'core_conversation_id', 'access_mode', 'grant_state', 'paired_ws_account_id', 'session_id', 'content_uid', 'thread_id', 'session_state', 'created_at', 'revoked_at']; const channelOperation = ['operation_id', 'status', 'steps', 'result', 'error_code']; @@ -56,7 +56,7 @@ function projectMutation(value: unknown): unknown { const source = object(value); return { ...(source.channel === undefined ? {} : { channel: projectChannelView(source.channel) }), - ...(source.target_agent === undefined ? {} : { target_agent: typeof source.target_agent === 'string' ? source.target_agent : projectChannelView(source.target_agent) }), + ...(source.target_agent === undefined ? {} : { target_agent: source.target_agent === null ? null : typeof source.target_agent === 'string' ? source.target_agent : projectChannelView(source.target_agent) }), ...(source.worker && typeof source.worker === 'object' ? { worker: copy(source.worker, ['account_id', 'address']) } : {}), ...(source.wechat && typeof source.wechat === 'object' ? { wechat: copy(source.wechat, ['status', 'message']) } : {}), ...(source.disconnected === undefined ? {} : { disconnected: source.disconnected === true }), @@ -180,6 +180,10 @@ function projectSchedule(value: unknown): unknown { function projectSchedules(value: unknown): unknown { return { jobs: list(object(value).jobs, projectSchedule) }; } function validateChannelInput(operation: string, args: ObjectRecord): void { const channelOperations = new Set([ + 'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', + 'disconnectChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus', + 'channelAccountWechatBindVerification', 'channelAccountWechatBindAccount', 'channelAccountWechatUnbind', + 'channelAccountOperation', 'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', 'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart', 'wechatBindStatus', 'wechatBindVerification', 'wechatBindAccount', 'wechatUnbind', 'channelSelfCallers', @@ -197,11 +201,17 @@ function validateChannelInput(operation: string, args: ObjectRecord): void { if (args.operation_id !== undefined && (typeof args.operation_id !== 'string' || args.operation_id.length > 128)) throw new Error('invalid_id'); } if (operation === 'createChannelBinding' && (typeof args.display_name !== 'string' || !args.display_name.trim() || args.display_name.length > 100)) throw new Error('invalid_input'); + if (operation === 'createChannelAccount' && (typeof args.display_name !== 'string' || !args.display_name.trim() || args.display_name.length > 100)) throw new Error('invalid_input'); + if (operation === 'routeChannelAccount' && args.target_agent_slug !== null + && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id'); + if (operation === 'routeChannelAccount' && args.target_agent_slug === null && args.enabled !== false) throw new Error('invalid_input'); if (operation === 'switchChannelBindingAgent' && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id'); if (operation === 'channelPolicy' && !['self_only', 'invited'].includes(String(args.access_mode))) throw new Error('invalid_input'); if (operation === 'createChannelPairing' && !['self', 'invite'].includes(String(args.kind))) throw new Error('invalid_input'); if (operation === 'wechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input'); + if (operation === 'channelAccountWechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input'); if (operation === 'wechatBindVerification' && (typeof args.verify_code !== 'string' || !/^[0-9A-Za-z-]{1,128}$/.test(args.verify_code))) throw new Error('invalid_input'); + if (operation === 'channelAccountWechatBindVerification' && (typeof args.verify_code !== 'string' || !/^[0-9A-Za-z-]{1,128}$/.test(args.verify_code))) throw new Error('invalid_input'); if (operation === 'channelConversationControl' && !['resume', 'stop', 'new-session'].includes(String(args.action))) throw new Error('invalid_input'); for (const key of ['expected_revision', 'expected_policy_revision']) { if (args[key] !== undefined && (!Number.isSafeInteger(args[key]) || Number(args[key]) < 0)) throw new Error('invalid_input'); @@ -274,6 +284,18 @@ const operations: Record = { deleteAttachment: { method: 'DELETE', path: '/threads/:thread_id/attachments/:file_id', output: ['message'] }, files: { method: 'GET', path: '/threads/:thread_id/files', query: { path: 'path' }, output: ['files'] }, channelConnections: { method: 'GET', path: '/channel-connections', output: ['items'], project: value => ({ items: list(object(value).items, projectChannelView) }) }, + channelAccounts: { method: 'GET', path: '/channel-accounts', output: ['items'], project: value => ({ items: list(object(value).items, projectChannelView) }) }, + createChannelAccount: { method: 'POST', path: '/channel-accounts', body: ['operation_id', 'display_name', 'account_key'], output: ['channel'], project: projectMutation }, + routeChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/route', body: ['operation_id', 'target_agent_slug', 'expected_revision', 'enabled'], output: ['channel', 'target_agent'], project: projectMutation }, + enableChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/enable', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation }, + pauseChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/pause', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation }, + disconnectChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/disconnect', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected'], project: projectMutation }, + channelAccountWechatBindStart: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/start', body: ['operation_id', 'force'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, + channelAccountWechatBindStatus: { method: 'GET', path: '/channel-accounts/:channel_account_id/wechat-bind/status', query: { session_key: 'session_key' }, output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, + channelAccountWechatBindVerification: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/verification', body: ['operation_id', 'session_key', 'verify_code'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, + channelAccountWechatBindAccount: { method: 'GET', path: '/channel-accounts/:channel_account_id/wechat-bind/account', output: ['status', 'message'] }, + channelAccountWechatUnbind: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/unbind', body: ['operation_id'], output: ['status', 'message'] }, + channelAccountOperation: { method: 'GET', path: '/channel-operations/:operation_id', output: channelOperation, project: projectOperation }, channelBindings: { method: 'GET', path: '/agents/:slug/channel-bindings', output: ['items', 'available_accounts'], project: projectBindings }, createChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings', body: ['operation_id', 'display_name', 'channel_account_id'], output: ['channel', 'worker'], project: projectMutation }, switchChannelBindingAgent: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/route', body: ['operation_id', 'target_agent_slug', 'expected_revision', 'enabled'], output: ['channel', 'target_agent'], project: projectMutation }, diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index 0326170..56e3f96 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -253,16 +253,18 @@ export class CloudAgentsModule { await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); return { discarded: true }; } - if (pending.operation === 'wechatBindVerification') { + if (pending.operation === 'wechatBindVerification' || pending.operation === 'channelAccountWechatBindVerification') { // The verification code is intentionally never persisted. First recover the // durable provider receipt: the code may already have been consumed even if // the response was lost. Only an absent receipt requires new input. const pendingInput = record(pending.input); const pendingSlug = typeof pendingInput.slug === 'string' ? pendingInput.slug : undefined; const operationId = typeof pendingInput.operation_id === 'string' ? pendingInput.operation_id : undefined; - if (pendingSlug && operationId) { + if (operationId && (pending.operation === 'channelAccountWechatBindVerification' || pendingSlug)) { try { - const receipt = await this.execute({ operation: 'channelOperation', input: { slug: pendingSlug, operation_id: operationId } }); + const receipt = await this.execute(pending.operation === 'channelAccountWechatBindVerification' + ? { operation: 'channelAccountOperation', input: { operation_id: operationId } } + : { operation: 'channelOperation', input: { slug: pendingSlug, operation_id: operationId } }); const status = typeof record(receipt).status === 'string' ? String(record(receipt).status).toLowerCase() : ''; const terminal = ['completed', 'complete', 'succeeded', 'success', 'failed', 'rejected', 'cancelled', 'canceled', 'expired', 'error'].includes(status); if (terminal) await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); @@ -332,12 +334,14 @@ export class CloudAgentsModule { catch { throw new CloudAgentsError(422, 'invalid_input'); } const invoke = async () => plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws')); const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild', + 'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount', + 'channelAccountWechatBindStart', 'channelAccountWechatBindVerification', 'channelAccountWechatUnbind', 'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', 'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart', 'wechatBindVerification', 'wechatUnbind', 'revokeChannelCaller', 'channelConversationControl', 'retryChannelDeliveryPart']; let result: unknown; if (recoverable.includes(plan.operation)) { - const journalInput = plan.operation === 'wechatBindVerification' + const journalInput = plan.operation === 'wechatBindVerification' || plan.operation === 'channelAccountWechatBindVerification' ? Object.fromEntries(Object.entries(plan.input).filter(([key]) => key !== 'verify_code')) : plan.input; result = await this.withRecovery(plan.operation, journalInput, invoke); diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index 24c71e6..dafa870 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -177,6 +177,9 @@ export interface CloudChannelView { policy_revision?: number; adoption_state?: 'available' | 'managed_elsewhere' | 'managed_external' | string; managed_agent_slug?: string | null; + target_agent_slug?: string | null; + target_agent_name?: string | null; + target_agent_published_version?: number | null; } export type CloudChannelAccountView = Partial & { id: string; @@ -190,6 +193,26 @@ export interface CloudChannelBindingPage { available_accounts?: CloudChannelAccountView[]; } export interface CloudChannelConnectionPage { items: CloudChannelView[] } +export interface CloudUserChannelAccount { + id: string; + display_name: string; + binding_id: string | null; + revision: number; + enabled: boolean; + status: string; + health: string; + blockers: string[]; + target_agent_slug: string | null; + target_agent_name: string | null; + target_agent_published_version: number | null; + access_mode?: string; + policy_revision?: number; + provider_generation?: string; + desired_state?: string; + last_confirmed_at?: string | null; +} +export interface CloudChannelAccountPage { items: CloudUserChannelAccount[] } +export interface CloudChannelAccountMutation { channel: CloudUserChannelAccount; target_agent?: string | null; wechat?: Record; disconnected?: boolean } export interface CloudChannelOperation { operation_id: string; status: 'pending' | 'completed' | 'failed' | 'unknown' | string; @@ -392,6 +415,18 @@ export interface CloudAgentOperations { deleteAttachment: Op; files: Op; channelConnections: Op, CloudChannelConnectionPage>; + channelAccounts: Op, CloudChannelAccountPage>; + createChannelAccount: Op; + routeChannelAccount: Op<{ channel_account_id: string; operation_id: string; target_agent_slug: string | null; expected_revision: number; enabled: boolean }, CloudChannelAccountMutation>; + enableChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; + pauseChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; + disconnectChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; + channelAccountWechatBindStart: Op<{ channel_account_id: string; operation_id: string; force: boolean }, CloudWechatBindState>; + channelAccountWechatBindStatus: Op<{ channel_account_id: string; session_key: string }, CloudWechatBindState>; + channelAccountWechatBindVerification: Op<{ channel_account_id: string; operation_id: string; session_key: string; verify_code: string }, CloudWechatBindState>; + channelAccountWechatBindAccount: Op<{ channel_account_id: string }, CloudChannelAccountStatus>; + channelAccountWechatUnbind: Op<{ channel_account_id: string; operation_id: string }, CloudChannelAccountStatus>; + channelAccountOperation: Op<{ operation_id: string }, CloudChannelOperation>; channelBindings: Op; createChannelBinding: Op; switchChannelBindingAgent: Op; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b145ced..13444f7 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,6 +12,7 @@ import { FolderKanban, LogOut, Plus, + RadioTower, Settings as SettingsIcon, Ticket, UserCircle2, @@ -783,10 +784,17 @@ export function Sidebar({ ) : ( activeModule === 'cloud_agents' ?
+
:
+ + {creating &&
+ + + +
} + {error &&
{error}
} + {notice &&

{notice}

} + {!accounts.length ?
+

还没有微信账号

即使还没有发布智能体,也可以先添加账号并扫码连接。

+
:
+ + {selected &&
+

{selected.display_name}

微信连接:{label(selected.health)} · 渠道路由:{selected.enabled ? '已启用' : '已暂停'}

+
+ {selected.blockers.length > 0 &&

{selected.blockers.map(item => blockerText[item] ?? item).join(';')}

} +
+ + +
+
+ {selected.health !== 'connected' ? + : } + {selected.enabled ? + : } + +
+ {qr && qrAccountId === selected.id &&
+
{qr.qrcode_url ? : }
+

{label(qr.status)}

扫码只负责账号连接,目标智能体可稍后选择。

{qr.message &&

{qr.message}

}
+
+ {qr.status === 'verification_required' && <> setVerifyCode(event.target.value)} placeholder="输入微信验证码" />} +
} + {confirmDisconnect &&

确认断开这个微信账号?

历史权限、活动和会话仍保留;再次使用需要重新扫码。

} + {selected.target_agent_slug && selected.binding_id ?
+ :

选择已发布的目标智能体后,可在这里管理联系人权限、活动记录和本人微信会话。

+
} +
} +
} + ; +} diff --git a/src/pages/CloudAgents/CloudAccess.tsx b/src/pages/CloudAgents/CloudAccess.tsx index 39edf16..6dcc9a3 100644 --- a/src/pages/CloudAgents/CloudAccess.tsx +++ b/src/pages/CloudAgents/CloudAccess.tsx @@ -7,7 +7,6 @@ import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudAccess as Access, CloudApplication, CloudKey } from '../../../shared/cloud-agents'; import { CloudCosts } from './CloudCosts'; import { CloudLifecycle } from './CloudLifecycle'; -import { CloudChannelPanel } from './CloudChannelPanel'; import type { CloudAgentDraft } from '../../../shared/cloud-agents'; import type { CloudAgentOperations } from '../../../shared/cloud-agents'; import { cloudStatus } from './CloudChat'; @@ -71,7 +70,7 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ
} - {access?.published_version && } + {access?.published_version && } {error &&

{error}

} {notice &&

{notice}

}

分享给指定用户

@@ -108,6 +107,31 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ ; } +function LinkedChannels({ slug, activeView }: { slug: string; activeView: boolean }) { + const [items, setItems] = useState([]); + const [error, setError] = useState(''); + useEffect(() => { + if (!activeView) return; + let live = true; + void cloudAgentsApi.call('channelBindings', { slug }).then(result => { + if (live) { setItems(result.items); setError(''); } + }).catch(error => { if (live) setError(errorText(error)); }); + return () => { live = false; }; + }, [activeView, slug]); + return
+

已关联渠道

+

微信账号统一在“渠道”中连接、切换目标和管理使用权限。

+
+ {error &&

{error}

} + {!error && items.length === 0 &&

暂时没有关联的微信账号。打开渠道后会预选当前智能体。

} + {items.map(item => )} +
; +} + function ApplicationKeys({ application, onUpdated }: { application: CloudApplication; onUpdated: () => Promise }) { const [keys, setKeys] = useState([]); const [secret, setSecret] = useState(''); diff --git a/src/pages/CloudAgents/CloudChannelConversations.tsx b/src/pages/CloudAgents/CloudChannelConversations.tsx index 34dce32..424b05f 100644 --- a/src/pages/CloudAgents/CloudChannelConversations.tsx +++ b/src/pages/CloudAgents/CloudChannelConversations.tsx @@ -22,7 +22,7 @@ function useVisible(active: boolean) { return active && visible; } -export function CloudChannelConversations({ slug, activeView = true }: { slug: string; activeView?: boolean }) { +export function CloudChannelConversations({ slug, channelAccountId, activeView = true }: { slug: string; channelAccountId?: string; activeView?: boolean }) { const [sessions, setSessions] = useState([]); const [selected, setSelected] = useState(''); const [offset, setOffset] = useState(null); @@ -37,15 +37,18 @@ export function CloudChannelConversations({ slug, activeView = true }: { slug: s setLoading(true); void cloudAgentsApi.call('channelConversations', { slug }).then(page => { if (!live) return; - setSessions(page.items); setOffset(page.next_offset); setError(''); - setSelected(current => current || page.items[0]?.session_id || ''); + const items = channelAccountId ? page.items.filter(item => item.channel_account_id === channelAccountId) : page.items; + setSessions(items); setOffset(page.next_offset); setError(''); + setSelected(current => items.some(item => item.session_id === current) ? current : items[0]?.session_id || ''); }).catch(error => { if (live) setError(failureText(error)); }).finally(() => { if (live) setLoading(false); }); return () => { live = false; }; - }, [slug, visible, revision]); + }, [channelAccountId, slug, visible, revision]); const more = async () => { if (loading || offset === null) return; setLoading(true); - try { const page = await cloudAgentsApi.call('channelConversations', { slug, offset }); setSessions(items => [...items, ...page.items]); setOffset(page.next_offset); } + try { const page = await cloudAgentsApi.call('channelConversations', { slug, offset }); + const next = channelAccountId ? page.items.filter(item => item.channel_account_id === channelAccountId) : page.items; + setSessions(items => [...items, ...next]); setOffset(page.next_offset); } catch (error) { setError(failureText(error)); } finally { setLoading(false); } }; return
diff --git a/src/pages/CloudAgents/CloudChannelPanel.tsx b/src/pages/CloudAgents/CloudChannelPanel.tsx index a5e3bf5..40847be 100644 --- a/src/pages/CloudAgents/CloudChannelPanel.tsx +++ b/src/pages/CloudAgents/CloudChannelPanel.tsx @@ -121,11 +121,13 @@ function recoveryPayload(value: unknown): unknown { return value; } -export function CloudChannelPanel({ slug, publishedVersion, activeView = true }: { slug: string; publishedVersion: number | null; activeView?: boolean }) { - const [tab, setTab] = useState('connect'); +export function CloudChannelPanel({ slug, publishedVersion, activeView = true, accountId, detailOnly = false }: { + slug: string; publishedVersion: number | null; activeView?: boolean; accountId?: string; detailOnly?: boolean; +}) { + const [tab, setTab] = useState(detailOnly ? 'access' : 'connect'); const [bindings, setBindings] = useState([]); const [accounts, setAccounts] = useState([]); - const [selectedBindingId, setSelectedBindingId] = useState(''); + const [selectedBindingId, setSelectedBindingId] = useState(accountId ?? ''); const [selectedAccountId, setSelectedAccountId] = useState(''); const [loadError, setLoadError] = useState(''); const [notice, setNotice] = useState(''); @@ -153,8 +155,8 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }: const currentBindingKeyRef = useRef(''); const binding = useMemo( - () => bindings.find(item => channelAccountId(item) === selectedBindingId) ?? bindings[0] ?? null, - [bindings, selectedBindingId], + () => bindings.find(item => channelAccountId(item) === (accountId ?? selectedBindingId)) ?? (accountId ? null : bindings[0] ?? null), + [accountId, bindings, selectedBindingId], ); const currentId = channelAccountId(binding); const currentBindingKey = binding ? `${currentId}:${binding.provider_generation}` : ''; @@ -204,13 +206,13 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }: if (!alive.current) return; setBindings(result.items); setAccounts(result.available_accounts ?? []); - setSelectedBindingId(current => result.items.some(item => channelAccountId(item) === current) - ? current - : channelAccountId(result.items[0])); + setSelectedBindingId(current => accountId && result.items.some(item => channelAccountId(item) === accountId) + ? accountId + : result.items.some(item => channelAccountId(item) === current) ? current : accountId ? '' : channelAccountId(result.items[0])); setSelectedAccountId(current => (result.available_accounts ?? []).some(item => channelAccountId(item) === current) ? current : ''); - }, [slug]); + }, [accountId, slug]); useEffect(() => { alive.current = true; @@ -228,13 +230,14 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }: 'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart', 'wechatBindVerification', 'revokeChannelCaller', 'retryChannelDeliveryPart', ]); - setPendingRecovery(recovery.pending.filter(item => channelOperations.has(item.operation) && item.input.slug === slug)); + setPendingRecovery(recovery.pending.filter(item => channelOperations.has(item.operation) && item.input.slug === slug + && (!accountId || item.input.channel_account_id === accountId))); setRecoveryReady(true); } catch (error) { if (alive.current) setLoadError(errorText(error)); setRecoveryReady(false); } - }, [slug]); + }, [accountId, slug]); useEffect(() => { void loadRecovery(); }, [loadRecovery]); @@ -607,8 +610,8 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }: const enabled = binding?.desired_state === 'enabled' || binding?.enabled === true; const canEnable = Boolean(binding && publishedVersion && connected); - return
-
+ return
+ {!detailOnly && <>

发布到个人微信

把已发布版本接到你的个人微信。云端持续执行,费用仍由创建者承担;关闭客户端不会中断云端任务。

@@ -619,14 +622,14 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }: {binding?.health && 微信连接服务:{statusLabel(binding.health)}} {binding?.last_confirmed_at && 最近确认:{shortDate(binding.last_confirmed_at)}} - + } {loadError &&
{loadError}
} {notice &&

{notice}

} {pendingRecovery.length > 0 &&

有 {pendingRecovery.length} 个微信渠道操作的结果尚未确认

恢复会使用原操作和原参数,不会新建渠道或重复扫码。

{pendingRecovery.map(item =>
{item.operation} · {shortDate(item.created_at)}
)}
} - {tab === 'connect' && void verify()} onEnable={() => void stateAction(true)} onPause={() => void stateAction(false)} onRouteTarget={setRouteTargetSlug} onOpenRoute={() => void openRoutePicker()} onSwitchRoute={() => void switchRoute()} onConfirmEnable={setConfirmEnable} onUnbind={() => void unbind()} onConfirmUnbind={setConfirmUnbind} />} - {tab === 'access' && void updateAccessMode()} onInvite={() => void issueInvite()} onClearPairing={() => setCreatedPairing(null)} onRevoke={caller => void revoke(caller)} />} + {tab === 'access' && void updateAccessMode()} onInvite={() => void issueInvite()} onClearPairing={() => setCreatedPairing(null)} onRevoke={caller => void revoke(caller)} />} {tab === 'activity' && void loadActivity()} onMore={() => void (activityOffset == null ? undefined : loadActivity(activityOffset))} onRetry={retryPart} />} - {tab === 'sessions' && } + {tab === 'sessions' && }
; } @@ -686,9 +689,9 @@ function StepHeading({ number, title, description }: { number: string; title: st return
{number}

{title}

{description}

; } -function AccessStep({ binding, accessMode, callers, pairing, busy, busyAction, onMode, onSave, onInvite, onClearPairing, onRevoke }: { binding: CloudChannelView | null; accessMode: 'self_only' | 'invited'; callers: CloudChannelCaller[]; pairing: CloudChannelPairing | null; busy: boolean; busyAction: string; onMode: (value: 'self_only' | 'invited') => void; onSave: () => void; onInvite: () => void; onClearPairing: () => void; onRevoke: (caller: CloudChannelCaller) => void }) { +function AccessStep({ binding, accessMode, callers, pairing, busy, busyAction, detailOnly = false, onMode, onSave, onInvite, onClearPairing, onRevoke }: { binding: CloudChannelView | null; accessMode: 'self_only' | 'invited'; callers: CloudChannelCaller[]; pairing: CloudChannelPairing | null; busy: boolean; busyAction: string; detailOnly?: boolean; onMode: (value: 'self_only' | 'invited') => void; onSave: () => void; onInvite: () => void; onClearPairing: () => void; onRevoke: (caller: CloudChannelCaller) => void }) { const inviteEnabled = accessMode === 'invited' && binding?.access_mode === 'invited'; - return
+ return
{!detailOnly && } {!binding ?

请先完成微信连接。

: <>
{accessMode === 'invited' &&

邀请联系人

邀请码需要你手动发给联系人;平台不自动向微信联系人发消息。

{!inviteEnabled &&

请先保存“本人及受邀联系人”后生成邀请码。

}
}
diff --git a/src/pages/CloudAgents/CloudRecovery.tsx b/src/pages/CloudAgents/CloudRecovery.tsx index 9d1ed69..0aa650b 100644 --- a/src/pages/CloudAgents/CloudRecovery.tsx +++ b/src/pages/CloudAgents/CloudRecovery.tsx @@ -8,7 +8,10 @@ const labels: Record = { createApplication: '创建应用', createKey: '创建密钥', createKnowledge: '创建知识库', processKnowledge: '处理文档', createSchedule: '创建自动任务', runSchedule: '立即运行任务', importKnowledgeAttachment: '导入附件到知识库', createChild: '创建子智能体', - wechatBindVerification: '微信连接验证', + wechatBindVerification: '微信连接验证', channelAccountWechatBindVerification: '微信连接验证', + createChannelAccount: '添加微信账号', routeChannelAccount: '切换目标智能体', enableChannelAccount: '启用渠道', + pauseChannelAccount: '暂停渠道', disconnectChannelAccount: '断开微信账号', channelAccountWechatBindStart: '微信扫码连接', + channelAccountWechatUnbind: '解除微信登录', }; export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) { @@ -22,7 +25,7 @@ export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOp const response = await cloudAgentsApi.resolvePending(item.id, discard); if (response.requires_input) { setResult(null); - setNotice('这次微信验证还需要验证码。请进入对应智能体的「发布与访问 → 个人微信」,继续连接验证。'); + setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。'); } else if (!discard) setResult({ operation: item.operation, value: response.result }); onResolved(); } catch (e) { setError(e instanceof Error ? e.message : '暂时无法确认结果'); } diff --git a/src/pages/CloudAgents/index.tsx b/src/pages/CloudAgents/index.tsx index fd36e05..f049c44 100644 --- a/src/pages/CloudAgents/index.tsx +++ b/src/pages/CloudAgents/index.tsx @@ -13,6 +13,7 @@ import { CloudChat } from './CloudChat'; import { CloudOverview } from './CloudOverview'; import { CloudPendingContext, useCloudPendingState } from './CloudPending'; import { CloudRecovery } from './CloudRecovery'; +import { ChannelAccounts } from './ChannelAccounts'; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : '暂时无法连接智能体服务,请重试'; @@ -241,5 +242,6 @@ function SharedConversation({ shared, activity, recent, onBack }: { export default function CloudAgents() { const accountId = useAuthStore((state) => state.user?.userId); const location = useLocation(); + if (location.pathname.startsWith('/cloud-agents/channels')) return ; return ; } diff --git a/tests/e2e/cloud-agent-channels.spec.ts b/tests/e2e/cloud-agent-channels.spec.ts index 6912508..6c15af5 100644 --- a/tests/e2e/cloud-agent-channels.spec.ts +++ b/tests/e2e/cloud-agent-channels.spec.ts @@ -2,6 +2,8 @@ import { closeElectronApp, expect, getStableWindow, test } from './fixtures/elec test('own WeChat conversation approval and generated PPT delivery in the desktop workspace', async ({ launchElectronApp }, testInfo) => { const app = await launchElectronApp({ skipSetup: true }); + const agentSlug = 'ml-' + 'a'.repeat(32); + const secondAgentSlug = 'ml-' + 'b'.repeat(32); try { const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); @@ -16,8 +18,12 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop const slug = 'ml-' + 'a'.repeat(32); const configuration = { model: 'test-model', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 }; const draft = { slug, name: '我的创作助手', purpose: '整理选题与制作演示文稿', system_prompt: '使用简洁自然的中文', draft_revision: 2, updated_at: '2026-09-13T00:00:00Z', configuration, published_version: 1, enabled: true, archived: false }; + const secondSlug = 'ml-' + 'b'.repeat(32); + const secondDraft = { ...draft, slug: secondSlug, name: '资料整理助手', draft_revision: 1, published_version: 1 }; const session = { session_id: 'self-session', caller_id: 'self-caller', agent_slug: slug, agent_name: draft.name, binding_id: 'binding-a', provider_generation: 'generation-a', channel_account_id: 'wechat-a', access_mode: 'self_only', grant_state: 'authorized', session_state: 'active', sequence: 1, thread_id: 'channel-thread', created_at: '2026-09-13T01:00:00Z' }; - const channel = { id: 'wechat-a', address: 'wechat:personal', display_name: '创作微信', target_agent_address: 'yuxi:makelore:' + slug, route_revision: 3, provider_generation: 'generation-a', binding_id: 'binding-a', enabled: true, status: 'active', worker_online: true, agent_slug: slug, published_version: 1, desired_state: 'enabled', sync_state: 'ready', health: 'connected', last_confirmed_at: '2026-09-13T01:00:00Z', revision: 3, blockers: [], access_mode: 'self_only', policy_revision: 0 }; + const channel = { id: 'wechat-a', address: 'wechat:personal', display_name: '创作微信', target_agent_address: 'yuxi:makelore:' + slug, route_revision: 3, provider_generation: 'generation-a', binding_id: 'binding-a', enabled: true, status: 'active', worker_online: true, agent_slug: slug, published_version: 1, desired_state: 'enabled', sync_state: 'ready', health: 'connected', last_confirmed_at: '2026-09-13T01:00:00Z', revision: 3, blockers: [], access_mode: 'self_only', policy_revision: 0, target_agent_slug: slug, target_agent_name: draft.name, target_agent_published_version: 1 }; + const spare = { id: 'wechat-b', address: 'wechat:spare', display_name: '备用微信', target_agent_address: null, route_revision: 1, provider_generation: 'generation-b', binding_id: 'binding-b', enabled: false, status: 'paused', worker_online: true, agent_slug: null, published_version: null, desired_state: 'disabled', sync_state: 'ready', health: 'login_required', last_confirmed_at: null, revision: 1, blockers: ['target_required'], access_mode: 'self_only', policy_revision: 0, target_agent_slug: null, target_agent_name: null, target_agent_published_version: null }; + let channelAccounts = [channel, spare]; const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); ipcMain.removeHandler('hostapi:fetch'); ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => { @@ -30,7 +36,7 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); if (path.endsWith('/recovery/recent')) return result({ saved: true }); if (path.endsWith('/files/save-channel')) { calls.push({ operation: 'downloadChannelArtifact', input: body }); return result({ saved: true }); } - if (!path.endsWith('/actions')) return result(path.endsWith('/agents') || path.endsWith('/bootstrap') ? { agents: [draft], next_cursor: null } : draft); + if (!path.endsWith('/actions')) return result(path.endsWith('/agents') || path.endsWith('/bootstrap') ? { agents: [draft, secondDraft], next_cursor: null } : path.endsWith(secondSlug) ? secondDraft : draft); const { operation, input = {} } = body; calls.push({ operation, input }); if (operation === 'threads') return result({ threads: [], next_offset: null }); @@ -40,12 +46,27 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop if (operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: 'niancode://agents/' + slug }); if (operation === 'budget') return result({ request_limit_points: '2.50', daily_limit_points: '10.00', daily_committed_points: '0.20' }); if (operation === 'costs') return result({ items: [], next_offset: null, summary: { count: 0, settled_points: '0', pending_points: '0' } }); - if (operation === 'channelBindings') return result({ items: [channel], available_accounts: [] }); + if (operation === 'channelAccounts') return result({ items: channelAccounts }); + if (operation === 'routeChannelAccount') { + const target = input.target_agent_slug === secondSlug ? secondDraft : input.target_agent_slug === slug ? draft : null; + channelAccounts = channelAccounts.map(item => item.id !== input.channel_account_id ? item : { ...item, revision: item.revision + 1, enabled: input.enabled, + target_agent_slug: target?.slug ?? null, target_agent_name: target?.name ?? null, target_agent_published_version: target?.published_version ?? null, + agent_slug: target?.slug ?? null, published_version: target?.published_version ?? null, target_agent_address: target ? 'yuxi:makelore:' + target.slug : null, + blockers: target ? [] : ['target_required'] }); + return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id), target_agent: target?.slug ?? null }); + } + if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') { + const enabled = operation === 'enableChannelAccount'; + channelAccounts = channelAccounts.map(item => item.id !== input.channel_account_id ? item : { ...item, revision: item.revision + 1, enabled, desired_state: enabled ? 'enabled' : 'disabled' }); + return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id) }); + } + if (operation === 'disconnectChannelAccount') return result({ channel: { ...channelAccounts.find(item => item.id === input.channel_account_id), health: 'login_required', enabled: false }, disconnected: true }); + if (operation === 'channelBindings') return result({ items: channelAccounts.filter(item => item.target_agent_slug === input.slug), available_accounts: [] }); if (operation === 'channelConversations') return result({ items: [session], next_offset: null }); if (operation === 'channelCallers' || operation === 'channelSelfCallers') return result({ items: [{ ...session, paired_ws_account_id: 'creator', revoked_at: null }] }); if (operation === 'channelActivity') return result({ items: [], next_offset: null }); - if (operation === 'wechatBindStart' || operation === 'wechatBindStatus') return result({ session_key: 'qr-session', status: verified ? 'confirmed' : 'verification_required', qrcode_url: 'https://login.example.test/confirm?token=fixture', message: '请确认微信验证码' }); - if (operation === 'wechatBindVerification') { verified = true; return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' }); } + if (operation === 'wechatBindStart' || operation === 'wechatBindStatus' || operation === 'channelAccountWechatBindStart' || operation === 'channelAccountWechatBindStatus') return result({ session_key: 'qr-session', status: verified ? 'confirmed' : 'verification_required', qrcode_url: 'https://login.example.test/confirm?token=fixture', message: '请确认微信验证码' }); + if (operation === 'wechatBindVerification' || operation === 'channelAccountWechatBindVerification') { verified = true; channelAccounts = channelAccounts.map(item => item.id === input.channel_account_id ? { ...item, health: 'connected' } : item); return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' }); } if (operation === 'createChannelPairing') return result({ invitation_id: 'pairing-a', binding_id: 'binding-a', provider_generation: 'generation-a', agent_slug: slug, kind: input.kind, expires_at: '2026-09-13T23:00:00Z', consumed: false, code: 'TEST1234' }); if (operation === 'channelConversation') return result({ ...session, messages: [{ id: 1, role: 'user', content: '把今天的创作建议整理成演示文稿。' }, ...(approved ? [{ id: 2, role: 'assistant', content: '## 演示文稿已完成\n共 7 页,文件已保存在本次会话中。' }] : [])], queued_requests: [], next_offset: null, run: { agent_run_id: 'channel-run', request_id: 'channel-request', thread_id: 'channel-thread', agent_slug: slug, status: approved ? 'completed' : 'interrupted', output: '', version: '1', @@ -74,34 +95,46 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop expect(calls.find(call => call.operation === 'downloadChannelArtifact')?.input).toEqual({ session_id: 'self-session', path: '/outputs/创作建议.pptx' }); expect(calls.some(call => call.operation === 'resume' || call.operation === 'submit')).toBe(false); await page.getByRole('button', { name: '发布与访问', exact: true }).click(); - await expect(page.getByText('创作微信', { exact: true }).first()).toBeVisible(); - await page.getByRole('region', { name: '个人微信渠道', exact: true }).scrollIntoViewIfNeeded(); - await page.getByRole('button', { name: '重新连接', exact: true }).click(); + const linked = page.getByRole('region', { name: '已关联渠道', exact: true }); + await expect(linked.getByText('创作微信', { exact: true })).toBeVisible(); + await expect(page.getByRole('region', { name: '个人微信渠道', exact: true })).toHaveCount(0); + await linked.scrollIntoViewIfNeeded(); + await page.screenshot({ path: testInfo.outputPath('agent-linked-channels-shortcut.png') }); + await linked.getByRole('button', { name: /创作微信/ }).click(); + await expect(page.getByRole('heading', { name: '微信', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /创作微信.*我的创作助手/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /备用微信.*未分配智能体/ })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('wechat-account-list.png'), fullPage: true }); + await page.getByRole('button', { name: /备用微信.*未分配智能体/ }).click(); + const spareDetail = page.getByRole('article', { name: '备用微信账号详情', exact: true }); + await spareDetail.getByRole('button', { name: '扫码连接', exact: true }).click(); await expect(page.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveAttribute('src', /^data:image\//); await page.getByLabel('微信验证码', { exact: true }).scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath('wechat-qr-verification.png') }); + await page.screenshot({ path: testInfo.outputPath('wechat-unassigned-qr-and-target.png'), fullPage: true }); await page.getByLabel('微信验证码', { exact: true }).fill('123456'); await page.getByRole('button', { name: '提交验证码', exact: true }).click(); - await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: unknown }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'wechatBindVerification'))).toBe(true); + await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: unknown }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'channelAccountWechatBindVerification'))).toBe(true); if (await page.getByRole('button', { name: '关闭二维码', exact: true }).count()) await page.getByRole('button', { name: '关闭二维码', exact: true }).click(); - await page.getByRole('button', { name: '使用权限', exact: true }).click(); - await expect(page.getByText('使用绑定时扫码的微信,直接发送消息即可。', { exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: '生成本人配对码', exact: true })).toHaveCount(0); - expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'createChannelPairing'))).toBe(false); - await page.screenshot({ path: testInfo.outputPath('wechat-access-without-pairing.png') }); - await page.getByRole('button', { name: '连接微信', exact: true }).click(); - await page.screenshot({ path: testInfo.outputPath('wechat-publication.png') }); + await spareDetail.getByLabel('目标智能体', { exact: true }).selectOption(agentSlug); + await spareDetail.getByRole('button', { name: '保存目标', exact: true }).click(); + await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record }[] }).cloudChannelFixtureCalls + .filter(call => call.operation === 'routeChannelAccount').some(call => call.input.channel_account_id === 'wechat-b' && call.input.target_agent_slug === 'ml-' + 'a'.repeat(32)))).toBe(true); + await expect(page.getByRole('button', { name: /备用微信.*我的创作助手/ })).toBeVisible(); + await page.getByRole('button', { name: /创作微信.*我的创作助手/ }).click(); + const legacyDetail = page.getByRole('article', { name: '创作微信账号详情', exact: true }); + const qrStartsBeforeSwitch = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length); + await legacyDetail.getByLabel('目标智能体', { exact: true }).selectOption(secondAgentSlug); + await legacyDetail.getByRole('button', { name: '保存目标', exact: true }).click(); + await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record }[] }).cloudChannelFixtureCalls + .filter(call => call.operation === 'routeChannelAccount').some(call => call.input.channel_account_id === 'wechat-a' && call.input.target_agent_slug === 'ml-' + 'b'.repeat(32) && call.input.enabled === true))).toBe(true); + expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length)).toBe(qrStartsBeforeSwitch); + await expect(page.getByRole('button', { name: '使用权限', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '活动记录', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '本人微信对话', exact: true })).toBeVisible(); await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); }); await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); - await page.getByRole('heading', { name: '发布到个人微信', exact: true }).scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath('wechat-publication-compact.png') }); - await page.getByRole('button', { name: '自动任务', exact: true }).click(); - await page.getByRole('button', { name: '添加任务', exact: true }).click(); - await expect(page.getByLabel('结果发送到')).toHaveValue(''); - await expect(page.getByRole('option', { name: '创作微信 · 本人对话 1' })).toBeAttached(); - await page.getByLabel('结果发送到').selectOption('self-caller'); - await page.getByLabel('结果发送到').scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath('wechat-schedule-target.png') }); + await page.screenshot({ path: testInfo.outputPath('wechat-account-detail-compact.png'), fullPage: true }); + expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'createChannelPairing'))).toBe(false); } finally { await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); await closeElectronApp(app); diff --git a/tests/unit/channel-accounts-page.test.tsx b/tests/unit/channel-accounts-page.test.tsx new file mode 100644 index 0000000..ccc056f --- /dev/null +++ b/tests/unit/channel-accounts-page.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { ChannelAccounts } from '@/pages/CloudAgents/ChannelAccounts'; + +const api = vi.hoisted(() => ({ call: vi.fn(), list: vi.fn(), recovery: vi.fn(), resolvePending: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +vi.mock('@/pages/CloudAgents/CloudWechatQr', () => ({ CloudWechatQr: () => 个人微信登录二维码 })); + +const agentA = { slug: 'ml-' + 'a'.repeat(32), name: '写作助手', published_version: 2, draft_revision: 3 }; +const agentB = { slug: 'ml-' + 'b'.repeat(32), name: '资料助手', published_version: 4, draft_revision: 5 }; +const legacy = { + id: 'wechat-legacy', display_name: '原有微信', binding_id: 'binding-legacy', revision: 7, enabled: true, + status: 'active', health: 'connected', blockers: [], target_agent_slug: agentA.slug, target_agent_name: agentA.name, + target_agent_published_version: 2, provider_generation: 'generation-legacy', access_mode: 'self_only', policy_revision: 1, +}; +const spare = { + id: 'wechat-spare', display_name: '备用微信', binding_id: 'binding-spare', revision: 2, enabled: false, + status: 'paused', health: 'login_required', blockers: ['target_required'], target_agent_slug: null, target_agent_name: null, + target_agent_published_version: null, provider_generation: 'generation-spare', access_mode: 'self_only', policy_revision: 0, +}; + +beforeEach(() => { + vi.clearAllMocks(); + api.list.mockResolvedValue({ agents: [agentA, agentB], next_cursor: null }); + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + api.call.mockImplementation(async (operation: string, input: Record) => { + if (operation === 'channelAccounts') return { items: [legacy, spare] }; + if (operation === 'channelAccountWechatBindStart') return { session_key: 'qr-spare', status: 'verification_required', qrcode_url: 'https://login.example.test/qr' }; + if (operation === 'routeChannelAccount') return { channel: { ...spare, target_agent_slug: input.target_agent_slug } }; + if (operation === 'channelBindings') return { items: [{ ...legacy, address: 'wechat:legacy', target_agent_address: 'agent:legacy', route_revision: 7, worker_online: true, agent_slug: agentA.slug, published_version: 2, desired_state: 'enabled', sync_state: 'ready' }], available_accounts: [] }; + if (operation === 'channelCallers') return { items: [] }; + throw new Error(`Unexpected operation ${operation}`); + }); +}); + +it('shows legacy and unassigned accounts and can connect before choosing an Agent', async () => { + render(); + expect(await screen.findByText('原有微信')).toBeVisible(); + expect(screen.getAllByText('备用微信')).toHaveLength(2); + const detail = await screen.findByRole('article', { name: '备用微信账号详情' }); + expect(within(detail).getByText('尚未选择目标智能体')).toBeVisible(); + fireEvent.click(within(detail).getByRole('button', { name: '扫码连接' })); + expect(await within(detail).findByRole('img', { name: '个人微信登录二维码' })).toBeVisible(); + expect(api.call).toHaveBeenCalledWith('channelAccountWechatBindStart', expect.objectContaining({ channel_account_id: 'wechat-spare' })); +}); + +it('routes two independent accounts to the same Agent without starting a new QR login', async () => { + render(); + const spareDetail = await screen.findByRole('article', { name: '备用微信账号详情' }); + fireEvent.change(within(spareDetail).getByLabelText('目标智能体'), { target: { value: agentA.slug } }); + fireEvent.click(within(spareDetail).getByRole('button', { name: '保存目标' })); + await waitFor(() => expect(api.call).toHaveBeenCalledWith('routeChannelAccount', expect.objectContaining({ + channel_account_id: 'wechat-spare', target_agent_slug: agentA.slug, enabled: false, + }))); + expect(api.call.mock.calls.some(([operation]) => operation === 'channelAccountWechatBindStart')).toBe(false); +}); + +it('switches an enabled legacy account target while preserving its enabled connection', async () => { + render(); + const detail = await screen.findByRole('article', { name: '原有微信账号详情' }); + fireEvent.change(within(detail).getByLabelText('目标智能体'), { target: { value: agentB.slug } }); + fireEvent.click(within(detail).getByRole('button', { name: '保存目标' })); + await waitFor(() => expect(api.call).toHaveBeenCalledWith('routeChannelAccount', expect.objectContaining({ + channel_account_id: 'wechat-legacy', target_agent_slug: agentB.slug, expected_revision: 7, enabled: true, + }))); + expect(api.call.mock.calls.some(([operation]) => String(operation).includes('WechatBindStart'))).toBe(false); +}); diff --git a/tests/unit/cloud-agents-main.test.ts b/tests/unit/cloud-agents-main.test.ts index 9a9fcbe..cb4eeec 100644 --- a/tests/unit/cloud-agents-main.test.ts +++ b/tests/unit/cloud-agents-main.test.ts @@ -201,6 +201,24 @@ describe('Main cloud Agents boundary', () => { }); }); + it('routes user-level channel accounts without requiring an Agent slug', () => { + const list = operationPlan({ operation: 'channelAccounts', input: {} }); + expect(list.path).toBe('/api/makelore/channel-accounts'); + expect(list.project({ items: [{ id: 'wechat-1', display_name: '工作微信', revision: 2, enabled: false, status: 'paused', health: 'connected', + blockers: ['target_required'], target_agent_slug: null, target_agent_name: null, target_agent_published_version: null, access_token: 'private' }] })).toEqual({ + items: [{ id: 'wechat-1', display_name: '工作微信', revision: 2, enabled: false, status: 'paused', health: 'connected', + blockers: ['target_required'], target_agent_slug: null, target_agent_name: null, target_agent_published_version: null }], + }); + const route = operationPlan({ operation: 'routeChannelAccount', input: { channel_account_id: 'wechat-1', operation_id: 'op-route', + target_agent_slug: draft.slug, expected_revision: 2, enabled: true } }); + expect(route.path).toBe('/api/makelore/channel-accounts/wechat-1/route'); + expect(route.body).toEqual({ operation_id: 'op-route', target_agent_slug: draft.slug, expected_revision: 2, enabled: true }); + expect(() => operationPlan({ operation: 'routeChannelAccount', input: { channel_account_id: 'wechat-1', operation_id: 'op-route', + target_agent_slug: null, expected_revision: 2, enabled: true } })).toThrow('invalid_input'); + expect(operationPlan({ operation: 'channelAccountOperation', input: { operation_id: 'op-route' } }).path) + .toBe('/api/makelore/channel-operations/op-route'); + }); + it('recovers a completed WeChat verification from its receipt without replaying the code', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) .mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } })); @@ -216,6 +234,19 @@ describe('Main cloud Agents boundary', () => { expect(fetchImpl.mock.calls[2][0]).toContain(`/api/makelore/channel-operations/${input.operation_id}?agent_slug=${draft.slug}`); }); + it('recovers user-level WeChat verification without an Agent slug', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) + .mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } })); + const module = moduleFor(fetchImpl); + const verification = { channel_account_id: 'channel-1', operation_id: input.operation_id, session_key: 'session-1', verify_code: 'secret-code-123' }; + await expect(module.execute({ operation: 'channelAccountWechatBindVerification', input: verification })).rejects.toMatchObject({ code: 'cloud_service_unavailable' }); + const pending = (await module.recovery()).pending[0]; + expect(pending.input).not.toHaveProperty('verify_code'); + expect(await module.resolvePending({ id: pending.id })).toMatchObject({ operation: 'channelAccountWechatBindVerification', result: { status: 'completed' } }); + expect(fetchImpl.mock.calls[2][0]).toContain(`/api/makelore/channel-operations/${input.operation_id}`); + expect(fetchImpl.mock.calls[2][0]).not.toContain('agent_slug'); + }); + it('asks for a new WeChat verification code only when its receipt is absent', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) .mockResolvedValueOnce(json({ detail: { code: 'operation_not_found' } }, 404));