From 2e710db0fb512f276fda8ae8a3939d366b34a972 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 13 Sep 2026 16:22:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(agents):=20=E6=94=AF=E6=8C=81=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E5=BE=AE=E4=BF=A1=E5=8F=91=E5=B8=83=E4=B8=8E=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260913-agentbus-channels-ml-3ec75b90.md | 56 ++ README.md | 2 + electron/api/routes/cloud-agents.ts | 2 + electron/services/cloud-agent-operations.ts | 241 +++++- electron/services/cloud-agents.ts | 122 ++- package.json | 2 + pnpm-lock.yaml | 154 +++- shared/cloud-agents.ts | 211 ++++- src/lib/cloud-agents-api.ts | 5 +- src/pages/CloudAgents/CloudAccess.tsx | 6 +- .../CloudAgents/CloudChannelConversations.tsx | 195 +++++ .../CloudChannelEnableConfirmation.tsx | 30 + src/pages/CloudAgents/CloudChannelPanel.tsx | 739 ++++++++++++++++++ src/pages/CloudAgents/CloudChat.tsx | 12 +- src/pages/CloudAgents/CloudCosts.tsx | 4 +- src/pages/CloudAgents/CloudRecovery.tsx | 12 +- src/pages/CloudAgents/CloudSchedules.tsx | 57 +- src/pages/CloudAgents/CloudWechatQr.tsx | 55 ++ src/pages/CloudAgents/DraftEditor.tsx | 18 +- tests/e2e/cloud-agent-channels.spec.ts | 108 +++ tests/e2e/cloud-agents.spec.ts | 2 + tests/unit/cloud-agents-main.test.ts | 84 ++ tests/unit/cloud-agents-page.test.tsx | 11 + tests/unit/cloud-agents-workflows.test.tsx | 74 +- .../unit/cloud-channel-conversations.test.tsx | 144 ++++ tests/unit/cloud-channel-enable.test.tsx | 31 + tests/unit/cloud-channel-panel.test.tsx | 302 +++++++ tests/unit/cloud-wechat-qr.test.tsx | 30 + 28 files changed, 2678 insertions(+), 31 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260913-agentbus-channels-ml-3ec75b90.md create mode 100644 src/pages/CloudAgents/CloudChannelConversations.tsx create mode 100644 src/pages/CloudAgents/CloudChannelEnableConfirmation.tsx create mode 100644 src/pages/CloudAgents/CloudChannelPanel.tsx create mode 100644 src/pages/CloudAgents/CloudWechatQr.tsx create mode 100644 tests/e2e/cloud-agent-channels.spec.ts create mode 100644 tests/unit/cloud-channel-conversations.test.tsx create mode 100644 tests/unit/cloud-channel-enable.test.tsx create mode 100644 tests/unit/cloud-channel-panel.test.tsx create mode 100644 tests/unit/cloud-wechat-qr.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260913-agentbus-channels-ml-3ec75b90.md b/.project-docs/30-worklog/tasks/20260913-agentbus-channels-ml-3ec75b90.md new file mode 100644 index 0000000..e545aec --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260913-agentbus-channels-ml-3ec75b90.md @@ -0,0 +1,56 @@ +# Task: Implement MakeLore AgentBus channel publication + +## Identity + +- Task ID: 20260913-agentbus-channels-ml-3ec75b90 +- Mode: Feature +- Branch: codex/20260913-agentbus-channels-ml-3ec75b90-agentbus-channels +- Worktree: C:\Users\7brot\.codex\worktrees\makelore-agentbus-channels-3ec75b90 +- Base commit: 03774d0625a5a0d0221dbb9989e88a9406e0fa48 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement the accepted AgentBus personal WeChat publication workflow in the native CloudAgents desktop workspace: connection/QR/verification, self pairing and invitations, same-owner route control, activity and delivery retry, own-session controls/artifacts, and scheduled result notification. +- Main/shared DTO, Host operations, local QR component/dependencies and final publication recovery regression tests are owned by works_square; Renderer publication/connection/access/activity by makelore; own-channel conversations, CloudChat approval reuse, DraftEditor integration, scheduled notifications/costs, enable-budget confirmation, Electron E2E/README and this record by root. The existing authorized Yuxi implementer supplies dedicated own-channel controls and artifact routes. Agents share this worktree and preserve each other's changes. + +## Intent And Constraints + +- Concurrent and Planning Gates Passed at base 03774d0. Official start/status match this identity. Both peer slices cover all 181 peers: 89 historical records lack Promotion Candidates and 38 have placeholder scope/intent, retained as unknown coordination details; no concrete conflict in readable context affects this isolated scope. +- Loaded startup/task/memory, positioning, integrated state, decisions including the accepted personal-cloud-agent ADR, architecture/module/data flow, domain/success criteria, evidence/reflection/commitments/stale indexes. Placeholder positioning and stale three-module prose are resolved by the accepted ADR and actual README; canonical documents remain untouched. +- User authorized the existing three implementers and one reviewer, client start, and exact nine-record missing-worktree recovery. The shared skill repair passed 17 tests, quick_validate and independent review; official release-missing succeeded for all nine authorized missing records before start. Primary's three untracked documents and unrelated registry ownership remain unchanged. +- Reuse AgentBus routing, shared SSO and individual owner isolation. Main owns credentials/network/recovery/file save; Renderer uses typed Host operations. Creator payment does not grant invited content. No org/pool/Console iframe, no automatic mutation replay, and no verification code persistence. +- No commit, merge, deployment, live WeChat message or paid model call in this implementation turn. + +## Outcome + +- Native publication is implemented: create/adopt connection, locally generated QR, verification, self pairing/invitation/revocation, enable/pause/switch/disconnect, safe activity and failed-file delivery retry. Main uses typed operations and per-account durable original inputs; verification codes never enter its journal. Implementation, independent review and local combined validation are complete. +- Root added explicit schedule notification selection, preservation/clearing of unavailable targets, channel cost source, and own-channel conversation entry with native control, operation recovery and auto-refreshed output file saving. History follows all native offset pages so recent outputs beyond message 100 remain visible. +- Disabled schedule updates omit unchanged notification configuration: Yuxi exclude_unset preserves the old target without revalidating a disconnected target, so the user can always pause. Explicit target selection/clearing and enabling still send notification config for normal server validation. +- CloudApproval now accepts an explicit submitDecision callback so ordinary conversation behavior is preserved and channel sessions use their dedicated controls with the actual action_requests/decisions protocol. No fallback to ordinary creator run/file endpoints. +- Failed controls transfer to Main's durable recovery record. A rejected new-session operation can be explicitly ignored before stopping an attachment intake that has not become a Run; failure to read the journal preserves the exact in-memory intent. Distinct failed-file parts keep separate account/message/part recovery IDs. +- Provider qrcode_url is a login payload, not an image resource. CloudWechatQr encodes it locally with qrcode 1.5.4 and displays a data SVG; changed values never show the previous code. Enable confirmation shows actual published audience and existing task/daily limits, including zero, with retry for unavailable budget data. +- Publication recovery uses the Main journal alone. Pending or unreadable recovery blocks new mutations; explicit refresh rereads both binding and recovery state. QR presentation starts only after a real result. A verification record cannot be consumed before its target binding is loaded/current. Binding and Provider-generation changes clear ephemeral data and reject late responses. Own route targets follow all list pages; invitations require the saved server policy. +- Activity separates execution and actual WeChat delivery. A known receipt is not delivery confirmation. File-part retry refreshes failed/queued/sent status without rerunning the model; connection health includes its last-confirmed time. The personal WeChat panel appears immediately after the publication overview. + +## Verification + +- Final pnpm typecheck and build:vite passed. The eight focused client suites passed 81/81: Main, workflows, page, management, own conversations, channel panel, local QR and enable confirmation. Changed source/test ESLint checks passed with three pre-existing CloudChat/CloudCosts warnings; git diff --check passed. Build retains the existing chunk-size/dynamic-import and Browserslist warnings. +- Final Electron run passed 2/2 after the last fixes. The isolated Host fixture covers native approval, automatic PPT exposure and session-scoped save, actual local data-image QR generation, verification, pairing, explicit schedule destination, compact-window overflow and existing draft/leave protection. Screenshots in ignored test-results were visually inspected at 1366×900 and 980×680. These are local fixture tests, not real WeChat/paid-model/production acceptance. +- Existing authorized independent reviewer returned final Standards PASS / Spec PASS with no remaining findings for Main, own sessions/schedules, local QR, enable confirmation and the complete publication panel. The last explicit-refresh increment was independently rerun with Panel 14/14 passing and targeted ESLint passing. Yuxi self bridge, real adoption HTTP contract and offset increments also passed review; latest affected Linux suite 73/73, Ruff and documentation gates passed, and Yuxi task is Ready for Integration. + +## Follow-ups + +### 2026-09-13 commit handoff + +- User authorized committing and merging this reviewed four-repository batch, and explicitly authorized transferring the four previous primary-worktree task registrations while preserving seven unrelated document changes. Earlier no-commit statements describe the implementation turn only. +- Same-task ownership was resumed and verified against the official registry. Planning context and the previous peer-scope assessment remain applicable; no product code changed during this handoff. +- Preserve the final independent Standards PASS / Spec PASS and recorded product-test evidence. The retained feature branch and this committed Ready for Integration record are the source for a separate integration task. +- No remote push, deployment, paid call, real WeChat message or worktree deletion is included in this authorization. + +- Integrate the matching AgentBus/Adapter, Works Square, Yuxi and MakeLore worktrees together, then configure/deploy compatible versions and perform real shared-SSO, personal WeChat, attachment/PPT, billing and scheduled-notification acceptance. No production identity linking, provider message, paid call, commit, merge or deployment occurred in this task. + +## Promotion Candidates + +- Target: personal-cloud-agent ADR, architecture and integrated current-state in a later Integration Gate. Proposal: native personal WeChat publication and own-session operation boundaries, account-scoped durable Main recovery and explicit scheduled notification targets. Evidence: this implementation, tests and matching AgentBus/WS/Yuxi tasks. Future impact: one personal creator workflow with private caller content and recoverable delivery. No conflict with accepted foundations. Product direction is already authorized; live production rollout and canonical promotion require their own actual integration work. diff --git a/README.md b/README.md index 52fd5c0..42a730e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 云知识库的向量模型由平台管理员配置:Yuxi 后台「智能体管理 → 模型供应商」启用 embedding 模型并填写实际维度;Works Square 运营后台「模型管理」从 one-api 刷新并激活相同模型 ID。one-api 必须支持该模型的向量接口。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 + 在「发布与访问」中可通过 AgentBus 连接个人微信:选用本人已有连接或扫码连接新微信,完成本人配对、设置联系人使用权限,再确认启用。渠道支持暂停、切换智能体、断开连接和查看运行/投递活动;失败的文件可单独补发。本人已配对的微信会话可在客户端查看、审批、停止、新开一段并保存实际产物;受邀联系人的内容不会因创建者付费而对创建者开放。自动任务可明确选择把结果发送到已配对的本人微信,暂停保留目标;费用统计包含微信渠道。渠道需要配套版本的 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/api/routes/cloud-agents.ts b/electron/api/routes/cloud-agents.ts index aa53ec5..87e7812 100644 --- a/electron/api/routes/cloud-agents.ts +++ b/electron/api/routes/cloud-agents.ts @@ -49,6 +49,8 @@ export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerR data = await cloudAgents.uploadSkill(); } else if (path === '/files/save' && req.method === 'POST') { data = await cloudAgents.download(await parseJsonBody(req)); + } else if (path === '/files/save-channel' && req.method === 'POST') { + data = await cloudAgents.downloadChannelArtifact(await parseJsonBody(req)); } else if (path === '/actions' && req.method === 'POST') { data = await cloudAgents.execute(await parseJsonBody(req)); } else if ((path === '/bootstrap' || path === '/agents') && req.method === 'GET') { diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index f9f2523..afa2013 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -2,11 +2,11 @@ import type { CloudAgentOperations } from '../../shared/cloud-agents'; type OperationSpec = { method: string; path: string; body?: readonly string[]; output: readonly string[]; - query?: Record; cloud?: 'ws'; + query?: Record; fixedQuery?: Record; cloud?: 'ws'; project?: (value: unknown) => unknown; }; const prompt = ['request_id', 'thread_id', 'query', 'expected_revision', 'attachment_file_ids']; const request = ['request_id', 'thread_id', 'run_id', 'status', 'version']; -const schedule = ['name', 'prompt', 'cron_expression', 'timezone', 'enabled']; +const schedule = ['name', 'prompt', 'cron_expression', 'timezone', 'enabled', 'result_notification']; const job = ['id', 'agent_slug', ...schedule, 'next_run_at', 'runs']; const entry = ['slug', 'name', 'purpose', 'published_version', 'is_creator', 'payer']; const budget = ['agent_slug', 'unit', 'timezone', 'request_limit_points', 'daily_limit_points', 'daily_committed_points', 'resets_at']; @@ -14,6 +14,204 @@ const draft = ['slug', 'name', 'purpose', 'system_prompt', 'draft_revision', 'up const mcpInput = ['name', 'description', 'transport', 'url', 'headers']; const mcpOutput = ['slug', 'name', 'description', 'transport', 'url', 'enabled', 'has_credentials']; 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']; +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']; + +type ObjectRecord = Record; +function object(value: unknown): ObjectRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as ObjectRecord : {}; +} +function copy(value: unknown, keys: readonly string[]): ObjectRecord { + const source = object(value); + return Object.fromEntries(keys.filter(key => source[key] !== undefined).map(key => [key, source[key]])); +} +function list(value: unknown, project: (item: unknown) => unknown, max = 200): unknown[] { + return Array.isArray(value) ? value.slice(0, max).map(project) : []; +} +function safeHttps(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length > 2048) return undefined; + try { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.hash) return undefined; + return url.toString(); + } catch { return undefined; } +} +function projectChannelView(value: unknown): ObjectRecord { + const result = copy(value, channelView); + if (Array.isArray(result.blockers)) result.blockers = result.blockers.filter((item): item is string => typeof item === 'string').slice(0, 20); + return result; +} +function projectBindings(value: unknown): unknown { + const source = object(value); + return { + items: list(source.items, projectChannelView), + ...(Array.isArray(source.available_accounts) ? { available_accounts: list(source.available_accounts, projectChannelView) } : {}), + }; +} +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.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 }), + }; +} +function projectPolicy(value: unknown): unknown { + const source = object(value); + return { + ...copy(source, ['binding_id', 'channel_account_id', 'access_mode', 'policy_revision']), + revoked_caller_ids: Array.isArray(source.revoked_caller_ids) ? source.revoked_caller_ids.filter((item): item is string => typeof item === 'string').slice(0, 200) : [], + revoked_invitation_ids: Array.isArray(source.revoked_invitation_ids) ? source.revoked_invitation_ids.filter((item): item is string => typeof item === 'string').slice(0, 200) : [], + }; +} +function projectPairing(value: unknown): unknown { return copy(value, ['invitation_id', 'binding_id', 'provider_generation', 'agent_slug', 'kind', 'expires_at', 'consumed', 'code']); } +function projectWechat(value: unknown): unknown { + const source = object(value); + const qrcode_url = safeHttps(source.qrcode_url); + return { ...copy(source, ['session_key', 'status', 'message']), ...(qrcode_url ? { qrcode_url } : {}) }; +} +function projectSession(value: unknown): unknown { return copy(value, channelSession); } +function projectRun(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['agent_run_id', 'request_id', 'thread_id', 'agent_slug', 'status', 'output', 'version']), + ...(source.error && typeof source.error === 'object' ? { error: copy(source.error, ['type', 'message']) } : {}), + ...(source.interrupt && typeof source.interrupt === 'object' ? { interrupt: copy(source.interrupt, ['status', 'message', 'run_id', 'questions', 'approval']) } : {}), + }; +} +function projectConversation(value: unknown): unknown { + const source = object(value); + return { ...projectSession(source), messages: list(source.messages, item => copy(item, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at'])), + run: source.run == null ? null : projectRun(source.run), + queued_requests: list(source.queued_requests, item => copy(item, ['request_id', 'thread_id', 'run_id', 'status', 'version'])), + next_offset: source.next_offset ?? null }; +} +function projectFiles(value: unknown): unknown { + const source = object(value); + return { session_id: source.session_id, thread_id: source.thread_id ?? null, + files: list(source.files, item => copy(item, ['name', 'path', 'directory_path', 'is_dir', 'size'])) }; +} +function projectCallers(value: unknown): unknown { return { items: list(object(value).items, item => copy(item, channelCaller)) }; } +function projectDeliverySlot(value: unknown): ObjectRecord { + return copy(value, ['state', 'message_id', 'status', 'artifact_status', 'artifact_count', 'artifact_failure_count']); +} +function projectActivityDelivery(value: unknown): ObjectRecord { + const source = object(value); + const result = copy(source, ['state', 'delivery_id', 'logical_message_id', 'request_id', 'run_id', 'inbound_message_id']); + for (const key of ['ack', 'progress', 'result'] as const) if (source[key] !== undefined) result[key] = projectDeliverySlot(source[key]); + const core = object(source.core); + if (Object.keys(core).length) { + const projectedCore = copy(core, ['state', 'core_status', 'adapter_status', 'provider_status', 'delivery_status', 'part_count', 'sent_part_count', 'failed_part_count']); + if (Array.isArray(core.parts)) { + projectedCore.parts = list(core.parts, part => { + const item = object(part); + return copy(item, ['part_id', 'type', 'core_status', 'adapter_status', 'provider_status', 'error_category']); + }); + } + result.core = projectedCore; + } + const outputMessageId = object(source.result).message_id; + if (typeof outputMessageId === 'string' && outputMessageId) result.logical_message_id = outputMessageId; + if (Array.isArray(core.parts)) { + result.parts = list(core.parts, part => { + const item = object(part); + return { part_id: item.part_id, kind: item.type, status: item.adapter_status ?? item.core_status ?? item.provider_status }; + }); + } + return result; +} +function projectActivity(value: unknown): unknown { + return { + items: list(object(value).items, item => { + const source = object(item); + const result = copy(source, ['run_id', 'request_id', 'agent_slug', 'caller_id', 'access_mode', 'binding_id', 'provider_generation', 'channel_account_id', 'status', 'source', 'channel', 'error_type']); + if (source.token_usage && typeof source.token_usage === 'object') result.token_usage = copy(source.token_usage, ['input_tokens', 'output_tokens', 'total_tokens', 'prompt_tokens', 'completion_tokens', 'cached_tokens']); + if (source.timing && typeof source.timing === 'object') result.timing = copy(source.timing, ['created_at', 'started_at', 'prepared_at', 'first_output_at', 'finished_at', 'first_model_request_at']); + if (source.delivery && typeof source.delivery === 'object') { + const delivery = object(source.delivery); + result.delivery = projectActivityDelivery(delivery); + } + if (source.input_preparation && typeof source.input_preparation === 'object') result.input_preparation = copy(source.input_preparation, ['intake_id', 'delivery_id', 'logical_message_id', 'state', 'prepared_file_count', 'native_request_id', 'native_run_id', 'error_type']); + return result; + }), + next_offset: object(value).next_offset ?? null, + }; +} +function projectDelivery(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['logical_message_id', 'message_id', 'status']), parts: list(source.parts, item => copy(item, ['part_id', 'part_index', 'type', 'status', 'attempts', 'sent_at'])) }; +} +function projectControl(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['operation_id', 'status', 'session_id', 'previous_session_id', 'run_id', 'request_id', 'intake_ids', 'action']), + ...(source.session && typeof source.session === 'object' ? { session: projectSession(source.session) } : {}) }; +} +function projectOperation(value: unknown): unknown { + const source = object(value); + const operationResult = source.result && typeof source.result === 'object' + ? (object(source.result).channel !== undefined ? projectMutation(source.result) : projectChannelView(source.result)) + : undefined; + return { ...copy(source, ['operation_id', 'status', 'error_code']), + ...(Array.isArray(source.steps) ? { steps: list(source.steps, item => copy(item, ['name', 'step', 'operation_id', 'status', 'error_code'])) } : {}), + ...(operationResult !== undefined ? { result: operationResult } : {}) }; +} +function projectScheduleNotification(value: unknown): unknown { + const source = object(value); + return copy(source, ['enabled', 'state', 'error_message', 'channel_account_id', 'caller_id']); +} +function projectSchedule(value: unknown): unknown { + const source = object(value); + const result = copy(source, ['id', 'agent_slug', 'name', 'prompt', 'cron_expression', 'timezone', 'enabled', 'next_run_at']); + if (source.result_notification !== undefined) result.result_notification = source.result_notification === null ? null : projectScheduleNotification(source.result_notification); + if (Array.isArray(source.runs)) { + result.runs = list(source.runs, item => { + const run = copy(item, ['status', 'thread_id', 'error_message', 'conversation_available', 'completed_at', 'run_id']); + if (object(item).result_notification !== undefined) run.result_notification = object(item).result_notification === null ? null : projectScheduleNotification(object(item).result_notification); + return run; + }, 20); + } + return result; +} +function projectSchedules(value: unknown): unknown { return { jobs: list(object(value).jobs, projectSchedule) }; } +function validateChannelInput(operation: string, args: ObjectRecord): void { + const channelOperations = new Set([ + 'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', + 'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart', + 'wechatBindStatus', 'wechatBindVerification', 'wechatBindAccount', 'wechatUnbind', 'channelSelfCallers', + 'channelCallers', 'revokeChannelCaller', 'channelActivity', 'channelConversations', 'channelConversation', + 'channelFiles', 'channelConversationControl', 'channelDelivery', 'retryChannelDeliveryPart', 'channelOperation', + ]); + if (channelOperations.has(operation)) { + for (const key of ['operation_id', 'channel_account_id', 'caller_id', 'session_id', 'session_key', 'logical_message_id', 'part_id', 'run_id']) { + const value = args[key]; + const valid = key === 'session_key' + ? typeof value === 'string' && /^[\x21-\x7e]{1,512}$/.test(value) + : typeof value === 'string' && /^[a-zA-Z0-9._:#-]{1,256}$/.test(value); + if (value !== undefined && !valid) throw new Error('invalid_id'); + } + 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 === '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 === 'wechatBindVerification' && (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'); + } + if (args.result_notification !== undefined && args.result_notification !== null) { + const target = object(args.result_notification); + if (target.enabled !== true || typeof target.channel_account_id !== 'string' || !/^[a-zA-Z0-9._:#-]{1,256}$/.test(target.channel_account_id) + || typeof target.caller_id !== 'string' || !/^[a-zA-Z0-9._:#-]{1,256}$/.test(target.caller_id)) throw new Error('invalid_input'); + } +} /** Only these product operations can cross the Main boundary; no arbitrary upstream URL or credentials. */ const operations: Record = { applicationCalls: { method: 'GET', path: '/applications/:application_id/calls', query: { offset: 'offset' }, output: ['items', 'next_offset'] }, @@ -65,9 +263,9 @@ const operations: Record = { run: { method: 'GET', path: '/runs/:run_id', output: ['agent_run_id', 'request_id', 'thread_id', 'agent_slug', 'status', 'output', 'version', 'error', 'interrupt'] }, cancelRun: { method: 'POST', path: '/runs/:run_id/cancel', output: ['status'] }, resume: { method: 'POST', path: '/runs/:run_id/resume', body: ['operation_id', 'decision'], output: ['run_id', 'status'] }, - schedules: { method: 'GET', path: '/agents/:slug/schedules', output: ['jobs'] }, - createSchedule: { method: 'POST', path: '/agents/:slug/schedules', body: ['operation_id', ...schedule], output: job }, - updateSchedule: { method: 'PUT', path: '/agents/:slug/schedules/:job_id', body: schedule, output: job }, + schedules: { method: 'GET', path: '/agents/:slug/schedules', output: ['jobs'], project: projectSchedules }, + createSchedule: { method: 'POST', path: '/agents/:slug/schedules', body: ['operation_id', ...schedule], output: job, project: projectSchedule }, + updateSchedule: { method: 'PUT', path: '/agents/:slug/schedules/:job_id', body: schedule, output: job, project: projectSchedule }, deleteSchedule: { method: 'DELETE', path: '/agents/:slug/schedules/:job_id', output: ['deleted'] }, runSchedule: { method: 'POST', path: '/agents/:slug/schedules/:job_id/run-now', body: ['operation_id'], output: ['thread_id', 'status'] }, attachments: { method: 'GET', path: '/threads/:thread_id/attachments', output: ['attachments'] }, @@ -75,6 +273,31 @@ const operations: Record = { confirmAttachment: { method: 'POST', path: '/threads/:thread_id/attachments/confirm', body: ['attachments'], output: ['attachments'] }, 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) }) }, + 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 }, + enableChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/enable', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation }, + pauseChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/pause', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation }, + disconnectChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/disconnect', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected'], project: projectMutation }, + channelPolicy: { method: 'PATCH', path: '/agents/:slug/channel-bindings/:channel_account_id', body: ['operation_id', 'access_mode', 'expected_policy_revision'], output: ['binding_id', 'channel_account_id', 'access_mode', 'policy_revision', 'revoked_caller_ids', 'revoked_invitation_ids'], project: projectPolicy }, + createChannelPairing: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/pairings', body: ['operation_id', 'kind'], output: ['invitation_id', 'binding_id', 'provider_generation', 'agent_slug', 'kind', 'expires_at', 'consumed', 'code'], project: projectPairing }, + wechatBindStart: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/start', body: ['operation_id', 'force'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, + wechatBindStatus: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/status', query: { session_key: 'session_key' }, output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, + wechatBindVerification: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/verification', body: ['operation_id', 'session_key', 'verify_code'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, + wechatBindAccount: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/account', output: ['status', 'message'] }, + wechatUnbind: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/unbind', body: ['operation_id'], output: ['status', 'message'] }, + channelSelfCallers: { method: 'GET', path: '/agents/:slug/channel-callers', fixedQuery: { access_mode: 'self_only' }, output: ['items'], project: projectCallers }, + channelCallers: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/callers', output: ['items'], project: projectCallers }, + revokeChannelCaller: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/callers/:caller_id/revoke', body: ['operation_id'], output: ['status', 'caller_id'] }, + channelActivity: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/activity', query: { offset: 'offset', limit: 'limit' }, output: ['items', 'next_offset'], project: projectActivity }, + channelConversations: { method: 'GET', path: '/channel-conversations', query: { slug: 'agent_slug', offset: 'offset' }, output: ['items', 'next_offset'], project: value => ({ items: list(object(value).items, projectSession), next_offset: object(value).next_offset ?? null }) }, + channelConversation: { method: 'GET', path: '/channel-conversations/:session_id', query: { offset: 'offset' }, output: ['session_id', 'caller_id', 'agent_slug', 'messages', 'run', 'queued_requests', 'next_offset'], project: projectConversation }, + channelFiles: { method: 'GET', path: '/channel-conversations/:session_id/files', query: { path: 'path' }, output: ['session_id', 'thread_id', 'files'], project: projectFiles }, + channelConversationControl: { method: 'POST', path: '/channel-conversations/:session_id/controls', body: ['operation_id', 'action', 'run_id', 'decision'], output: ['operation_id', 'status', 'session_id', 'action'], project: projectControl }, + channelDelivery: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/deliveries/:logical_message_id', output: ['logical_message_id', 'message_id', 'status', 'parts'], project: projectDelivery }, + retryChannelDeliveryPart: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/deliveries/:logical_message_id/parts/:part_id/retry', output: ['logical_message_id', 'message_id', 'status', 'parts'], project: projectDelivery }, + channelOperation: { method: 'GET', path: '/channel-operations/:operation_id', query: { slug: 'agent_slug' }, output: channelOperation, project: projectOperation }, }; export function operationPlan(value: unknown) { @@ -84,18 +307,21 @@ export function operationPlan(value: unknown) { || !input || typeof input !== 'object' || Array.isArray(input)) throw new Error('invalid_operation'); const spec = operations[operation as keyof CloudAgentOperations]; const args = input as Record; + validateChannelInput(operation, args); const routeKeys = [...spec.path.matchAll(/:([a-z_]+)/g)].map(match => match[1]); const path = spec.path.replace(/:([a-z_]+)/g, (_, key: string) => { const id = args[key]; - if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(id) + if (typeof id !== 'string' || !/^[a-zA-Z0-9._:#-]{1,256}$/.test(id) || (key === 'slug' && !/^ml-[a-f0-9]{32}$/.test(id))) throw new Error('invalid_id'); return encodeURIComponent(id); }); const query = new URLSearchParams(); + for (const [name, value] of Object.entries(spec.fixedQuery ?? {})) query.set(name, value); for (const [key, name] of Object.entries(spec.query ?? {})) { const v = args[key]; if (v === undefined) continue; - if (key === 'archived' ? typeof v !== 'boolean' : key === 'offset' ? !Number.isSafeInteger(v) || Number(v) < 0 : typeof v !== 'string' || v.length > (key === 'path' ? 2048 : 100)) { + if (key === 'archived' ? typeof v !== 'boolean' : key === 'offset' || key === 'limit' ? !Number.isSafeInteger(v) || Number(v) < 0 || (key === 'limit' && Number(v) > 100) : typeof v !== 'string' || v.length > (key === 'path' ? 2048 : key === 'session_key' ? 512 : 200) + || (key === 'slug' || key === 'agent_slug') && !/^ml-[a-f0-9]{32}$/.test(String(v))) { throw new Error('invalid_query'); } query.set(name, String(v)); @@ -107,6 +333,7 @@ export function operationPlan(value: unknown) { ...spec, path: (spec.cloud === 'ws' ? '/api/cloud-agents' : '/api/makelore') + path + (query.size ? '?' + query : ''), body: spec.body ? Object.fromEntries(spec.body.filter(key => args[key] !== undefined).map(key => [key, args[key]])) : undefined, project: (result: unknown) => { + if (spec.project) return spec.project(result); if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('invalid_response'); const record = result as Record; return Object.fromEntries(spec.output.filter(key => record[key] !== undefined).map(key => [key, record[key]])); diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index 610005e..59723b9 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -37,6 +37,34 @@ const MESSAGES: Record = { agent_archived: '请先从归档恢复智能体,再启用', attachment_too_large: '附件最大支持 5 MB', download_failed: '文件保存失败,请重试', + channel_not_found: '微信渠道不存在或已解绑', + channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态', + channel_request_invalid: '微信渠道请求无效,请刷新后重试', + channel_rate_limited: '微信渠道操作过于频繁,请稍后重试', + channel_conversation_not_found: '微信会话不存在或已关闭', + channel_session_control_failed: '微信会话操作失败,请刷新后重试', + channel_delivery_not_found: '渠道交付记录不存在', + channel_delivery_retry_failed: '文件补发失败,请刷新交付记录后重试', + pairing_expired: '配对码已过期,请重新生成', + verification_required: '请先完成微信验证', + artifact_unavailable: '文件已不可用,请刷新文件列表', + operation_unavailable: '操作状态暂时无法确认,请稍后查询', + channel_scope_changed: '微信渠道版本已变化,请刷新后重试', + binding_revoked: '微信渠道已暂停、切换或撤销', + binding_paused: '微信渠道已暂停,请先启用后重试', + caller_not_found: '渠道调用者不存在', + delivery_not_found: '交付记录不存在', + delivery_part_not_found: '交付文件分片不存在', + artifact_snapshot_missing: '文件快照尚未准备好,请稍后重试', + channel_control_failed: '微信会话控制失败,请刷新后重试', + run_active: '当前会话仍在执行,请稍后重试', + run_not_active: '当前会话没有可操作的运行', + channel_session_unavailable: '新的微信会话暂时无法建立', + pairing_invalid: '配对请求无效,请刷新后重试', + invitation_invalid: '配对码无效或已撤销', + invitation_consumed: '配对码已经使用', + invites_disabled: '当前渠道仅允许创建者本人配对', + agentbus_unavailable: '渠道服务暂时不可用,请稍后重试', }; export class CloudAgentsError extends Error { @@ -177,7 +205,11 @@ export class CloudAgentsModule { private async withRecovery(operation: string, input: Record, execute: () => Promise): Promise { const binding = getWorksSquareAccountBinding(); if (!binding) throw new CloudAgentsError(401, 'session_expired'); - const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', input.request_id ?? input.operation_id].join(':'); + const operationKey = operation === 'retryChannelDeliveryPart' + ? [input.channel_account_id, input.logical_message_id, input.part_id] + .map(value => encodeURIComponent(typeof value === 'string' ? value : '')).join(':') + : input.request_id ?? input.operation_id; + const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', operationKey].join(':'); await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); this.requireCurrent(binding); const result = await execute(); @@ -220,6 +252,28 @@ export class CloudAgentsModule { await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); return { discarded: true }; } + if (pending.operation === 'wechatBindVerification') { + // 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) { + try { + const receipt = await this.execute({ 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)); + return { operation: pending.operation, input: pending.input, result: receipt, ...(terminal ? {} : { pending: true }) }; + } catch (error) { + if (!(error instanceof CloudAgentsError) || error.status !== 404) throw error; + } + } + // No receipt exists (or this is an old record without enough metadata), so + // the Renderer must collect a fresh verification code. + return { operation: pending.operation, input: pending.input, requires_input: true }; + } const result = pending.operation === 'createAgent' ? await this.create(pending.input) : await this.execute({ operation: pending.operation, input: pending.input }); return { operation: pending.operation, input: pending.input, result }; @@ -276,8 +330,17 @@ export class CloudAgentsModule { try { plan = operationPlan(value); } 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']; - const result = await (recoverable.includes(plan.operation) ? this.withRecovery(plan.operation, plan.input, invoke) : invoke()); + const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild', + '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' + ? Object.fromEntries(Object.entries(plan.input).filter(([key]) => key !== 'verify_code')) + : plan.input; + result = await this.withRecovery(plan.operation, journalInput, invoke); + } else result = await invoke(); try { const projected = plan.project(result); if (record(value).operation === 'access') { @@ -395,6 +458,59 @@ export class CloudAgentsModule { } finally { await fs.unlink(temporary).catch(() => undefined); } } + /** Save a personal-channel artifact through the session-scoped bridge. */ + async downloadChannelArtifact(value: unknown): Promise<{ saved: boolean }> { + const input = record(value); + const sessionId = textField(input.session_id, 256); + const path = textField(input.path, 2048); + if (!/^[a-zA-Z0-9._:#-]{1,256}$/.test(sessionId) + || path.includes('\\') || path.includes('\0') || /:\/\//.test(path) + || path.replace(/^\/+/, '').split('/').some(part => !part || part === '..' || part === '.')) { + throw new CloudAgentsError(422, 'invalid_input'); + } + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const { dialog } = await import('electron'); + const fs = await import('node:fs/promises'); + const { basename, dirname, join } = await import('node:path'); + const picked = await dialog.showSaveDialog({ title: '保存微信渠道文件', defaultPath: basename(path), + properties: ['createDirectory', 'showOverwriteConfirmation'] }); + this.requireCurrent(binding); + if (picked.canceled || !picked.filePath) return { saved: false }; + const temporary = join(dirname(picked.filePath), '.makelore-channel-download-' + crypto.randomUUID()); + try { + await runWithDeadline(async signal => { + const session = await this.session(binding, signal); + this.requireCurrent(binding); + const encodedPath = path.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'); + const url = session.apiBaseUrl + '/api/makelore/channel-conversations/' + encodeURIComponent(sessionId) + + '/artifacts/' + encodedPath + '?download=true'; + const response = await this.fetchImpl(url, { redirect: 'error', signal, headers: { Authorization: 'Bearer ' + session.accessToken } }); + if (!response.ok || !response.body) throw new CloudAgentsError(response.status || 502, 'download_failed'); + const output = await fs.open(temporary, 'wx'); + const reader = response.body.getReader(); + let size = 0; + try { + while (true) { + const { value: chunk, done } = await reader.read(); + this.requireCurrent(binding); + if (done) break; + size += chunk.byteLength; + if (size > 1024 * 1024 * 1024) throw new CloudAgentsError(413, 'download_failed'); + let offset = 0; + while (offset < chunk.byteLength) offset += (await output.write(chunk, offset, chunk.byteLength - offset)).bytesWritten; + } + } finally { await reader.cancel().catch(() => undefined); await output.close(); } + }, 300000); + this.requireCurrent(binding); + await fs.rename(temporary, picked.filePath); + return { saved: true }; + } catch(error) { + if (error instanceof CloudAgentsError) throw error; + throw new CloudAgentsError(502, 'download_failed'); + } finally { await fs.unlink(temporary).catch(() => undefined); } + } + async *events(runId: string, after: string, signal: AbortSignal): AsyncGenerator { if (!/^[a-zA-Z0-9_-]{1,64}$/.test(runId) || !/^\d+-\d+$/.test(after)) { throw new CloudAgentsError(422, 'invalid_input'); diff --git a/package.json b/package.json index 6e8d62f..5d3e9f1 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "node-machine-id": "^1.1.12", "npm": "11.6.2", "posthog-node": "^5.28.0", + "qrcode": "1.5.4", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", "ws": "^8.19.0", @@ -149,6 +150,7 @@ "@testing-library/react": "^16.3.2", "@types/adm-zip": "0.5.5", "@types/node": "^25.3.0", + "@types/qrcode": "1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3350b00..6b57e13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: posthog-node: specifier: ^5.28.0 version: 5.28.5 + qrcode: + specifier: 1.5.4 + version: 1.5.4 rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -147,6 +150,9 @@ importers: '@types/node': specifier: ^25.3.0 version: 25.5.0 + '@types/qrcode': + specifier: 1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -2260,6 +2266,9 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2694,6 +2703,10 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} @@ -2762,6 +2775,9 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2882,6 +2898,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2942,6 +2962,9 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -3230,6 +3253,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3722,6 +3749,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4270,10 +4301,18 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -4286,6 +4325,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -4373,6 +4416,10 @@ packages: resolution: {integrity: sha512-GDEQJr8OG4e6JMp7mABtXFSEpgJa1CCpbQiAR+EjhkHJHnUL9zPPtbOrjsMD8gUbikgv3j7x404b0YJsV3aVFA==} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -4495,6 +4542,11 @@ packages: resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -4636,6 +4688,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -4732,6 +4787,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -5305,6 +5363,9 @@ packages: when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -5324,6 +5385,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5358,6 +5423,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5382,10 +5450,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -5939,7 +6015,7 @@ snapshots: hosted-git-info: 9.0.3 ignore: 7.0.5 jiti: 2.7.0 - minimatch: 10.2.4 + minimatch: 10.2.5 proper-lockfile: 4.1.2 semver: 7.8.0 typebox: 1.3.7 @@ -7401,6 +7477,10 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.6.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -7921,6 +8001,8 @@ snapshots: camelcase-css@2.0.1: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001781: {} ccount@2.0.1: {} @@ -7980,6 +8062,12 @@ snapshots: string-width: 4.2.3 optional: true + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -8089,6 +8177,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: @@ -8142,6 +8232,8 @@ snapshots: diff@9.0.0: {} + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -8523,6 +8615,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -9100,6 +9197,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -9787,10 +9888,18 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -9802,6 +9911,8 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parse-entities@4.0.2: @@ -9874,6 +9985,8 @@ snapshots: png2icons@2.0.1: {} + pngjs@5.0.0: {} + postcss-import@15.1.0(postcss@8.5.8): dependencies: postcss: 8.5.8 @@ -9977,6 +10090,12 @@ snapshots: qrcode-terminal@0.12.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -10148,6 +10267,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -10258,6 +10379,8 @@ snapshots: type-fest: 0.13.1 optional: true + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} sharp@0.34.5: @@ -10874,6 +10997,8 @@ snapshots: when-exit@2.1.5: {} + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -10889,6 +11014,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -10911,6 +11042,8 @@ snapshots: xmlchars@2.2.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -10923,8 +11056,27 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index 1ba4a3c..24c71e6 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -118,23 +118,205 @@ export interface CloudRecoveryState { recent: CloudRecent | null; pending: CloudPendingOperation[]; } +export interface CloudScheduleResultNotification { + enabled: true; + channel_account_id: string; + caller_id: string; +} +export type CloudScheduleResultNotificationTarget = CloudScheduleResultNotification | null; export interface CloudScheduleInput { name: string; prompt: string; cron_expression: string; timezone: string; enabled: boolean; + result_notification?: CloudScheduleResultNotificationTarget; } export interface CloudSchedule extends CloudScheduleInput { id: string; agent_slug: string; next_run_at: string | null; - runs?: { status: string; thread_id: string; error_message?: string; conversation_available: boolean }[]; + result_notification?: CloudScheduleResultNotificationTarget; + runs?: { + status: string; thread_id: string; error_message?: string; conversation_available: boolean; + result_notification?: { + enabled: boolean; + state: string; + error_message?: string | null; + channel_account_id?: string; + caller_id?: string; + } | null; + }[]; } export interface CloudCost { id: string; status: string; reserved_points: string; actual_points: string | null; created_at: string; context: { agent_slug: string; version: string; caller_kind: string; caller_id: string; creator_id?: string; run_id: string; request_id: string; source?: CloudCostSource | null }; } -export type CloudCostSource = 'self' | 'shared' | 'api' | 'scheduled' | 'preview' | 'knowledge'; +export type CloudCostSource = 'self' | 'shared' | 'api' | 'scheduled' | 'preview' | 'knowledge' | 'channel'; export interface CloudCostPage { unit: string; items: CloudCost[]; next_offset: number | null; summary: { count: number; settled_points: string; pending_points: string } } export interface CloudBudget { agent_slug: string; unit: string; timezone: string; request_limit_points: string | null; daily_limit_points: string | null; daily_committed_points: string; resets_at: string } export interface CloudAttachment { file_id: string; file_name: string; file_size: number; path: string; original_path: string; request_id?: string | null } export interface CloudUpload { object_name: string; file_name: string; file_type: string; parse_supported: boolean; parse_methods: string[] } export interface CloudFile { name: string; path: string; directory_path: string; is_dir: boolean; size: number } + +/** Safe, credential-free projections returned by the personal channel bridge. */ +export interface CloudChannelView { + id: string; + address: string; + display_name: string; + target_agent_address: string; + route_revision: number; + provider_generation: string; + binding_id: string; + enabled: boolean; + status: string; + worker_online: boolean; + agent_slug?: string; + published_version?: number | null; + desired_state?: string; + sync_state?: string; + health?: string; + last_confirmed_at?: string | null; + revision?: number; + blockers?: string[]; + access_mode?: string; + policy_revision?: number; + adoption_state?: 'available' | 'managed_elsewhere' | 'managed_external' | string; + managed_agent_slug?: string | null; +} +export type CloudChannelAccountView = Partial & { + id: string; + display_name?: string; + target_agent_address?: string | null; + adoption_state?: 'available' | 'managed_elsewhere' | 'managed_external' | string; + managed_agent_slug?: string | null; +}; +export interface CloudChannelBindingPage { + items: CloudChannelView[]; + available_accounts?: CloudChannelAccountView[]; +} +export interface CloudChannelConnectionPage { items: CloudChannelView[] } +export interface CloudChannelOperation { + operation_id: string; + status: 'pending' | 'completed' | 'failed' | 'unknown' | string; + steps?: { name?: string; step?: string; operation_id?: string; status: string; error_code?: string | null }[]; + result?: Record | null; + error_code?: string | null; +} +export interface CloudChannelSession { + session_id: string; + caller_id: string; + agent_slug: string; + agent_name?: string; + binding_id: string; + provider_generation: string; + channel_account_id: string; + core_conversation_id: string | null; + access_mode: 'self_only' | 'invited' | string; + grant_state: string; + session_state: string; + sequence?: number; + content_uid?: string | null; + thread_id: string | null; + created_at: string; + closed_at?: string | null; +} +export interface CloudChannelConversation extends CloudChannelSession { + messages: CloudMessage[]; + run: CloudRun | null; + queued_requests: CloudRequest[]; + next_offset: number | null; +} +export interface CloudChannelConversationPage { + items: CloudChannelSession[]; + next_offset: number | null; +} +export interface CloudChannelFilesPage { + session_id: string; + thread_id: string | null; + files: CloudFile[]; +} +export interface CloudChannelCaller { + caller_id: string; + binding_id: string; + provider_generation: string; + channel_account_id: string; + agent_slug: string; + core_conversation_id: string | null; + access_mode: 'self_only' | 'invited' | string; + grant_state: string; + paired_ws_account_id: string | null; + session_id: string | null; + content_uid: string | null; + thread_id: string | null; + session_state: string; + created_at: string; + revoked_at: string | null; +} +export interface CloudChannelCallerPage { items: CloudChannelCaller[] } +export interface CloudChannelActivity { + run_id?: string | null; + request_id?: string | null; + session_id?: string | null; + status: string; + created_at?: string; + started_at?: string | null; + finished_at?: string | null; + token_usage?: Record | null; + output_available?: boolean; + timing?: Record; + delivery?: { + state?: string; logical_message_id?: string | null; message_id?: string | null; + ack?: Record; progress?: Record; result?: Record; + parts?: { part_id?: string; kind?: string; status?: string }[]; + core?: { state?: string; core_status?: string; adapter_status?: string; provider_status?: string; delivery_status?: string; parts?: { part_id?: string; type?: string; core_status?: string; adapter_status?: string; provider_status?: string; error_category?: string }[] }; + }; + input_preparation?: { state?: string; error_type?: string | null }; +} +export interface CloudChannelActivityPage { items: CloudChannelActivity[]; next_offset: number | null } +export interface CloudChannelPairing { + invitation_id?: string; + binding_id: string; + provider_generation: string; + agent_slug?: string; + kind: 'self' | 'invite' | string; + expires_at?: string; + consumed: boolean; + code?: string; +} +export interface CloudWechatBindState { + session_key: string; + status: string; + message?: string; + qrcode_url?: string; +} +export interface CloudChannelDeliveryPart { + part_id: string; + part_index?: number; + type?: string; + status: string; + attempts?: number; + sent_at?: string | null; +} +export interface CloudChannelDelivery { + logical_message_id?: string; + message_id?: string; + status: string; + parts: CloudChannelDeliveryPart[]; +} +export interface CloudChannelWorker { account_id: string; address: string } +export interface CloudChannelMutation { channel: CloudChannelView; target_agent?: string | CloudChannelView; worker?: CloudChannelWorker; wechat?: Record; disconnected?: boolean } +export interface CloudChannelPolicy { + binding_id: string; + channel_account_id: string; + access_mode: 'self_only' | 'invited' | string; + policy_revision: number; + revoked_caller_ids: string[]; + revoked_invitation_ids: string[]; +} +export interface CloudChannelControl { + operation_id: string; status: string; session_id: string; action: 'resume' | 'stop' | 'new-session'; + previous_session_id?: string; + run_id?: string; + request_id?: string; + intake_ids?: string[]; + session?: CloudChannelSession; +} +export interface CloudChannelAccountStatus { status: string; message?: string } export interface CloudKnowledge { kb_id: string; name: string; description: string; embedding_model: string } export interface CloudKnowledgeFile { file_id: string; name: string; size: number; status: string; error: string | null; chunk_count: number } export interface CloudMcp { slug: string; name: string; description: string; transport: 'sse' | 'streamable_http'; url: string; enabled: boolean; has_credentials: boolean } @@ -209,4 +391,29 @@ export interface CloudAgentOperations { confirmAttachment: Op; deleteAttachment: Op; files: Op; + channelConnections: Op, CloudChannelConnectionPage>; + channelBindings: Op; + createChannelBinding: Op; + switchChannelBindingAgent: Op; + enableChannelBinding: Op; + pauseChannelBinding: Op; + disconnectChannelBinding: Op; + channelPolicy: Op; + createChannelPairing: Op; + wechatBindStart: Op; + wechatBindStatus: Op; + wechatBindVerification: Op; + wechatBindAccount: Op; + wechatUnbind: Op; + channelSelfCallers: Op; + channelCallers: Op; + revokeChannelCaller: Op; + channelActivity: Op; + channelConversations: Op; + channelConversation: Op<{ session_id: string; offset?: number }, CloudChannelConversation>; + channelFiles: Op<{ session_id: string; path?: string }, CloudChannelFilesPage>; + channelConversationControl: Op<{ session_id: string; operation_id: string; action: 'resume' | 'stop' | 'new-session'; run_id?: string; decision?: Record }, CloudChannelControl>; + channelDelivery: Op; + retryChannelDeliveryPart: Op; + channelOperation: Op; } diff --git a/src/lib/cloud-agents-api.ts b/src/lib/cloud-agents-api.ts index 3b608da..d87acec 100644 --- a/src/lib/cloud-agents-api.ts +++ b/src/lib/cloud-agents-api.ts @@ -7,7 +7,7 @@ export const cloudAgentsApi = { remember: (recent: CloudRecent | null) => hostApiFetch(CLOUD_AGENTS_PATH + '/recovery/recent', { method: 'PUT', body: JSON.stringify(recent), }), - resolvePending: (id: string, discard = false) => hostApiFetch<{ result?: unknown; operation?: string; input?: Record; discarded?: boolean }>( + resolvePending: (id: string, discard = false) => hostApiFetch<{ result?: unknown; operation?: string; input?: Record; discarded?: boolean; requires_input?: boolean; pending?: boolean }>( CLOUD_AGENTS_PATH + '/recovery/pending', { method: 'POST', body: JSON.stringify({ id, discard }) }), uploadSkill: () => hostApiFetch(CLOUD_AGENTS_PATH + '/skills/pick', { method: 'POST' }), uploadKnowledge: (slug: string, kb_id: string, operation_id: string, replaces_file_id?: string) => @@ -18,6 +18,9 @@ export const cloudAgentsApi = { download: (thread_id: string, path: string) => hostApiFetch<{ saved: boolean }>(CLOUD_AGENTS_PATH + '/files/save', { method: 'POST', body: JSON.stringify({ thread_id, path }), }), + downloadChannelArtifact: (session_id: string, path: string) => hostApiFetch<{ saved: boolean }>(CLOUD_AGENTS_PATH + '/files/save-channel', { + method: 'POST', body: JSON.stringify({ session_id, path }), + }), call: (operation: K, input: CloudAgentOperations[K]['input']) => hostApiFetch(CLOUD_AGENTS_PATH + '/actions', { method: 'POST', body: JSON.stringify({ operation, input }), diff --git a/src/pages/CloudAgents/CloudAccess.tsx b/src/pages/CloudAgents/CloudAccess.tsx index 3837a42..39edf16 100644 --- a/src/pages/CloudAgents/CloudAccess.tsx +++ b/src/pages/CloudAgents/CloudAccess.tsx @@ -7,12 +7,13 @@ 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'; const errorText = (e: unknown) => e instanceof Error ? e.message : '操作失败,请重试'; -export function CloudAccessPanel({ slug, revision, onPublished, onChanged }: { slug: string; revision: number; onPublished: () => void; onChanged?: (draft: CloudAgentDraft) => void }) { +export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activeView = true }: { slug: string; revision: number; onPublished: () => void; onChanged?: (draft: CloudAgentDraft) => void; activeView?: boolean }) { const [access, setAccess] = useState(null); const [query, setQuery] = useState(''); const [users, setUsers] = useState<{ account_id: string; display_name: string; username: string }[]>([]); @@ -65,11 +66,12 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged }: { s

{access?.published_version ? '当前发布版本 ' + access.published_version : '尚未发布'}

发布保存的修订 {revision}。发布后仅自己可用,分享与应用需要分别开启。

-

自己使用、分享使用、API 调用和自动任务产生的费用,均从你的个人词元点数扣除。你可以随时停用智能体或撤销访问。

+

自己使用、分享使用、API 调用、自动任务和个人微信渠道产生的费用,均从你的个人词元点数扣除。你可以随时停用智能体或撤销访问。

{access?.published_version &&
{access.enabled ? '智能体已启用' : '智能体已停用'}
} + {access?.published_version && } {error &&

{error}

} {notice &&

{notice}

}

分享给指定用户

diff --git a/src/pages/CloudAgents/CloudChannelConversations.tsx b/src/pages/CloudAgents/CloudChannelConversations.tsx new file mode 100644 index 0000000..1ed9423 --- /dev/null +++ b/src/pages/CloudAgents/CloudChannelConversations.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Download, Folder, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { cloudAgentsApi } from '@/lib/cloud-agents-api'; +import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents'; +import { CloudApproval, cloudStatus } from './CloudChat'; +import { usePendingCloudInput } from './CloudPending'; + +const failureText = (error: unknown) => error instanceof Error ? error.message : '微信会话暂时无法读取'; +const activeRun = (status?: string) => ['pending', 'queued', 'running', 'interrupted', 'preparing'].includes(status ?? ''); +type Control = CloudAgentOperations['channelConversationControl']['input']; + +function useVisible(active: boolean) { + const [visible, setVisible] = useState(document.visibilityState !== 'hidden'); + useEffect(() => { + const update = () => setVisible(document.visibilityState !== 'hidden'); + document.addEventListener('visibilitychange', update); + return () => document.removeEventListener('visibilitychange', update); + }, []); + return active && visible; +} + +export function CloudChannelConversations({ slug, activeView = true }: { slug: string; activeView?: boolean }) { + const [sessions, setSessions] = useState([]); + const [selected, setSelected] = useState(''); + const [offset, setOffset] = useState(null); + const [revision, setRevision] = useState(0); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const visible = useVisible(activeView); + useEffect(() => { + if (!visible) return; + let live = true; + 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 || ''); + }).catch(error => { if (live) setError(failureText(error)); }).finally(() => { if (live) setLoading(false); }); + return () => { live = false; }; + }, [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); } + catch (error) { setError(failureText(error)); } finally { setLoading(false); } + }; + return
+

本人微信对话

+

查看你已配对的本人会话,处理确认和保存文件。新消息请从对应微信对话发送。

+
+ {error &&

{error}

} + {loading && !sessions.length &&

正在读取本人会话…

} + {!loading && !error && !sessions.length &&

还没有本人微信会话。请先在发布与访问中连接个人微信,并完成本人配对。

} + {!!sessions.length && } + {offset !== null && } + {selected && { setSelected(id); setRevision(value => value + 1); }} />} +
; +} + +function ChannelSession({ sessionId, visible, refreshRevision, onBusy, onNewSession }: { + sessionId: string; visible: boolean; refreshRevision: number; onBusy: (busy: boolean) => void; onNewSession: (id: string) => void; +}) { + const [data, setData] = useState(null); + const [files, setFiles] = useState([]); + const [outputs, setOutputs] = useState([]); + const [path, setPath] = useState('/'); + const [revision, setRevision] = useState(0); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [fileError, setFileError] = useState(''); + const [notice, setNotice] = useState(''); + const [pending, setPending] = useState([]); + const [recoveryReady, setRecoveryReady] = useState(false); + const [intent, setIntent] = useState(null); + const locked = busy || !recoveryReady || Boolean(intent) || pending.length > 0; + usePendingCloudInput(locked); + useEffect(() => { onBusy(locked); return () => onBusy(false); }, [locked, onBusy]); + const loadRecovery = useCallback(async () => { + const recovery = await cloudAgentsApi.recovery(); + setPending(recovery.pending.filter(item => item.operation === 'channelConversationControl' && item.input.session_id === sessionId)); + setRecoveryReady(true); + }, [sessionId]); + useEffect(() => { void loadRecovery().catch(error => setError(failureText(error))); }, [loadRecovery]); + useEffect(() => { + if (!visible) return; + let live = true; + let timer: ReturnType; + const load = async () => { + try { + let next = await cloudAgentsApi.call('channelConversation', { session_id: sessionId }); + while (live && next.next_offset !== null) { + const more = await cloudAgentsApi.call('channelConversation', { session_id: sessionId, offset: next.next_offset }); + next = { ...more, messages: [...next.messages, ...more.messages] }; + } + if (live) setData(next); + } + catch (error) { if (live) setError(failureText(error)); } + finally { if (live) timer = setTimeout(() => void load(), 5000); } + }; + void load(); + return () => { live = false; clearTimeout(timer); }; + }, [sessionId, visible, revision, refreshRevision]); + const runId = data?.run?.agent_run_id; + const runStatus = data?.run?.status; + const threadId = data?.thread_id; + useEffect(() => { + if (!visible || !threadId) return; + let live = true; + setFileError(''); + void cloudAgentsApi.call('channelFiles', { session_id: sessionId, path }).then(async page => { + if (!live) return; + setFiles(page.files); setOutputs([]); + const outputDirectory = path === '/' && page.files.find(file => file.is_dir && file.name === 'outputs'); + if (outputDirectory) { + const outputPage = await cloudAgentsApi.call('channelFiles', { session_id: sessionId, path: outputDirectory.directory_path }); + if (live) setOutputs(outputPage.files.filter(file => !file.is_dir)); + } + }).catch(error => { if (live) setFileError(failureText(error)); }); + return () => { live = false; }; + }, [sessionId, path, visible, threadId, runId, runStatus, revision, refreshRevision]); + const control = async (input: Control) => { + const frozen = intent ?? input; + setIntent(frozen); setBusy(true); setError(''); + try { + const result = await cloudAgentsApi.call('channelConversationControl', frozen); + setIntent(null); setPending([]); + if (frozen.action === 'new-session') onNewSession(result.session_id); + else setRevision(value => value + 1); + } catch (error) { + setError(failureText(error)); + // Main retains accepted or uncertain operations. Move recovery to that + // durable record so a rejected control can be dismissed before another. + try { await loadRecovery(); setIntent(null); } catch { /* Keep the exact input available if the journal cannot be read. */ } + throw error; + } + finally { setBusy(false); } + }; + const resolve = async (operation: CloudPendingOperation, discard = false) => { + setBusy(true); setError(''); + try { + const response = await cloudAgentsApi.resolvePending(operation.id, discard); + await loadRecovery(); + const result = response.result; + if (!discard && operation.input.action === 'new-session' && result && typeof result === 'object' + && 'session_id' in result && typeof result.session_id === 'string') onNewSession(result.session_id); + else setRevision(value => value + 1); + } + catch (error) { setError(failureText(error)); } finally { setBusy(false); } + }; + const save = async (file: CloudFile) => { + setBusy(true); setFileError(''); setNotice(''); + try { const result = await cloudAgentsApi.downloadChannelArtifact(sessionId, file.path); if (result.saved) setNotice('已保存 ' + file.name); } + catch (error) { setFileError(failureText(error)); } finally { setBusy(false); } + }; + const controllable = data?.session_state === 'active' && data.grant_state === 'authorized'; + const running = activeRun(runStatus) || Boolean(data?.queued_requests?.length); + const renderFiles = (items: CloudFile[]) => items.map(file =>
+ {file.is_dir ? + : <>{file.name}} +
); + return
+

{data?.session_state === 'closed' ? '历史会话 · 可以查看消息和保存文件' : runStatus ? cloudStatus(runStatus) : '等待微信消息'}

+ {controllable &&
+
}
+ {pending.map(operation =>

上次会话操作的结果尚未确认。恢复会核对同一次操作。

+ +
)} + {intent && } + {error &&

{error}

} +
+ {data?.messages.filter(message => message.role !== 'system').map(message =>
+

{message.role === 'user' ? '你 · 微信' : '智能体'}

href && /^(https?:|mailto:)/i.test(href) ? {children} : {children} }}>{message.content}
)} + {data && !data.messages.length &&

还没有消息。

} +
+ {!recoveryReady && } + {recoveryReady && controllable && runStatus === 'interrupted' && data?.run?.interrupt && runId && !pending.length && !intent && control({ ...input, session_id: sessionId, action: 'resume', run_id: runId })} />} +

文件与产物

+
+ {outputs.length > 0 &&

生成的文件

{renderFiles(outputs)}
} + {path !== '/' && } + {renderFiles(files)} + {!files.length && !fileError &&

暂时没有文件。运行结束时会自动更新。

} + {fileError &&

{fileError}

}{notice &&

{notice}

} +
+
; +} diff --git a/src/pages/CloudAgents/CloudChannelEnableConfirmation.tsx b/src/pages/CloudAgents/CloudChannelEnableConfirmation.tsx new file mode 100644 index 0000000..5d08c99 --- /dev/null +++ b/src/pages/CloudAgents/CloudChannelEnableConfirmation.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { cloudAgentsApi } from '@/lib/cloud-agents-api'; +import type { CloudBudget, CloudChannelView } from '../../../shared/cloud-agents'; + +export function CloudChannelEnableConfirmation({ slug, publishedVersion, binding, busy, onEnable, onCancel }: { + slug: string; publishedVersion: number | null; binding: CloudChannelView; busy: boolean; + onEnable: () => void; onCancel: () => void; +}) { + const [budget, setBudget] = useState(null); + const [error, setError] = useState(''); + const [revision, setRevision] = useState(0); + useEffect(() => { + let live = true; + void cloudAgentsApi.call('budget', { slug }).then(value => { + if (live) { setBudget(value); setError(''); } + }).catch(error => { if (live) setError(error instanceof Error ? error.message : '暂时无法读取费用上限'); }); + return () => { live = false; }; + }, [slug, revision]); + const limit = (value: string | null) => value === null ? '不设额外上限' : `${value} 词元点数`; + return
+

确认启用个人微信渠道?

+

{binding.display_name || '个人微信'}将使用当前发布版本 v{publishedVersion},允许{binding.access_mode === 'invited' ? '本人及受邀联系人' : '仅本人'}发送任务。

+

费用从你的词元点数扣除,与这个智能体的其他使用方式共用上限。

+ {budget ?

每次任务上限:{limit(budget.request_limit_points)}

每日上限:{limit(budget.daily_limit_points)}

+ : !error &&

正在读取费用上限…

} + {error &&

{error}

} +
+
; +} diff --git a/src/pages/CloudAgents/CloudChannelPanel.tsx b/src/pages/CloudAgents/CloudChannelPanel.tsx new file mode 100644 index 0000000..64b305d --- /dev/null +++ b/src/pages/CloudAgents/CloudChannelPanel.tsx @@ -0,0 +1,739 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { CircleAlert, Copy, Loader2, Pause, Play, QrCode, RefreshCw, RotateCcw, ShieldCheck, Unplug, Users, Wifi, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; +import { cloudAgentsApi } from '@/lib/cloud-agents-api'; +import type { + CloudAgentOperations, + CloudAgentDraft, + CloudChannelActivity, + CloudChannelCaller, + CloudChannelAccountView, + CloudChannelDelivery, + CloudChannelPairing, + CloudChannelView, + CloudPendingOperation, + CloudWechatBindState, +} from '../../../shared/cloud-agents'; +import { usePendingCloudInput } from './CloudPending'; +import { CloudChannelConversations } from './CloudChannelConversations'; +import { CloudChannelEnableConfirmation } from './CloudChannelEnableConfirmation'; +import { CloudWechatQr } from './CloudWechatQr'; + +type PanelTab = 'connect' | 'access' | 'activity' | 'sessions'; +type ChannelMutationName = + | 'createChannelBinding' + | 'switchChannelBindingAgent' + | 'enableChannelBinding' + | 'pauseChannelBinding' + | 'disconnectChannelBinding' + | 'channelPolicy' + | 'createChannelPairing' + | 'wechatBindStart' + | 'wechatBindVerification' + | 'revokeChannelCaller'; + +const statusLabels: Record = { + connected: '已连接', bound: '已连接', enabled: '已启用', active: '运行中', running: '执行中', + completed: '已完成', sent: '已送达', failed: '失败', paused: '已暂停', disabled: '已停用', + offline: '暂时离线', login_required: '需要扫码', provisioning: '准备中', ready: '就绪', + waiting: '等待中', pending: '处理中', verification_required: '需要验证码', scanned: '已扫码', + confirmed: '已确认', expired: '已过期', revoked: '已撤销', unknown: '状态未知', + queued: '排队中', preparing: '准备中', input_failed: '输入准备失败', delivery_failed: '投递失败', +}; +const blockerLabels: Record = { + publish_required: '请先发布智能体版本', + channel_sync_degraded: '渠道状态同步异常,请刷新后重试', + worker_offline: '渠道服务暂时离线', + login_required: '请先连接个人微信', +}; + +function statusLabel(value: unknown): string { + const key = String(value || 'unknown'); + return statusLabels[key] ?? key; +} + +function deliveryLabel(value: unknown): string { + const labels: Record = { + sent: '已送达', delivered: '已送达', failed: '发送失败', dead: '发送失败', + partial: '部分发送失败', queued: '等待发送', pending: '等待发送', + accepted: '已接收,等待投递', processing: '正在发送', + waiting_context: '等待新的微信消息后再发送', unknown: '状态未知', + }; + return typeof value === 'string' ? labels[value] ?? '状态待确认' : '状态未知'; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : '渠道操作失败,请刷新后重试'; +} + +function shortDate(value?: string | null): string { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.valueOf()) ? value : date.toLocaleString(); +} + +function operationId(): string { + return crypto.randomUUID(); +} + +function channelAccountId(view: CloudChannelView | CloudChannelAccountView | null | undefined): string { + return view?.id ?? ''; +} + +function displayAccount(view: CloudChannelView | CloudChannelAccountView): string { + return view.display_name || '个人微信'; +} + +function isConnected(view: CloudChannelView | null): boolean { + return view?.health === 'connected'; +} + +function blockerLabel(value: string): string { + return blockerLabels[value] ?? '渠道配置还未就绪'; +} + +function tokenUsageLabel(value: Record | null | undefined): string | null { + if (!value || Object.keys(value).length === 0) return null; + const total = value.total_tokens ?? value.total ?? value.tokens; + return typeof total === 'number' || typeof total === 'string' ? `词元 ${total}` : '账单处理中'; +} + +function isTerminalQr(state: CloudWechatBindState | null): boolean { + return Boolean(state && ['confirmed', 'expired', 'failed'].includes(state.status)); +} + +function isWechatBindState(value: unknown): value is CloudWechatBindState { + return Boolean(value && typeof value === 'object' && typeof (value as { session_key?: unknown }).session_key === 'string' + && typeof (value as { status?: unknown }).status === 'string'); +} + +function isPairing(value: unknown): value is CloudChannelPairing { + return Boolean(value && typeof value === 'object' && typeof (value as { kind?: unknown }).kind === 'string' + && typeof (value as { binding_id?: unknown }).binding_id === 'string'); +} + +function recoveryPayload(value: unknown): unknown { + if (value && typeof value === 'object' && 'result' in value && value.result && typeof value.result === 'object') { + return value.result; + } + return value; +} + +export function CloudChannelPanel({ slug, publishedVersion, activeView = true }: { slug: string; publishedVersion: number | null; activeView?: boolean }) { + const [tab, setTab] = useState('connect'); + const [bindings, setBindings] = useState([]); + const [accounts, setAccounts] = useState([]); + const [selectedBindingId, setSelectedBindingId] = useState(''); + const [selectedAccountId, setSelectedAccountId] = useState(''); + const [loadError, setLoadError] = useState(''); + const [notice, setNotice] = useState(''); + const [busy, setBusy] = useState(false); + const [busyAction, setBusyAction] = useState(''); + const [qr, setQr] = useState(null); + const [qrBindingKey, setQrBindingKey] = useState(''); + const [qrOpen, setQrOpen] = useState(false); + const [verificationCode, setVerificationCode] = useState(''); + const [createdPairing, setCreatedPairing] = useState(null); + const [accessMode, setAccessMode] = useState<'self_only' | 'invited'>('self_only'); + const [callers, setCallers] = useState([]); + const [activity, setActivity] = useState([]); + const [activityOffset, setActivityOffset] = useState(null); + const [activityRevision, setActivityRevision] = useState(0); + const [confirmEnable, setConfirmEnable] = useState(false); + const [confirmUnbind, setConfirmUnbind] = useState(false); + const [routeTargetSlug, setRouteTargetSlug] = useState(''); + const [routeAgents, setRouteAgents] = useState([]); + const [routePickerOpen, setRoutePickerOpen] = useState(false); + const [pendingRecovery, setPendingRecovery] = useState([]); + const [recoveryReady, setRecoveryReady] = useState(false); + const alive = useRef(true); + const currentIdRef = useRef(''); + const currentBindingKeyRef = useRef(''); + + const binding = useMemo( + () => bindings.find(item => channelAccountId(item) === selectedBindingId) ?? bindings[0] ?? null, + [bindings, selectedBindingId], + ); + const currentId = channelAccountId(binding); + const currentBindingKey = binding ? `${currentId}:${binding.provider_generation}` : ''; + currentBindingKeyRef.current = currentBindingKey; + const activeQr = qrBindingKey === currentBindingKey ? qr : null; + const activePairing = createdPairing && binding + && createdPairing.binding_id === binding.binding_id + && createdPairing.provider_generation === binding.provider_generation + ? createdPairing : null; + const bindingAccessMode = binding?.access_mode; + + useEffect(() => { + currentIdRef.current = currentId; + setQr(null); + setQrBindingKey(''); + setQrOpen(false); + setVerificationCode(''); + setCreatedPairing(null); + setCallers([]); + setActivity([]); + setActivityOffset(null); + setActivityRevision(0); + }, [currentBindingKey, currentId]); + + const selectBinding = useCallback((value: string) => { + const next = bindings.find(item => channelAccountId(item) === value); + const nextKey = next ? `${value}:${next.provider_generation}` : value; + currentIdRef.current = value; + currentBindingKeyRef.current = nextKey; + setQr(null); + setQrBindingKey(''); + setQrOpen(false); + setVerificationCode(''); + setCreatedPairing(null); + setCallers([]); + setActivity([]); + setActivityOffset(null); + setActivityRevision(0); + setRouteTargetSlug(''); + setRouteAgents([]); + setRoutePickerOpen(false); + setSelectedBindingId(value); + }, [bindings]); + + const refresh = useCallback(async () => { + const result = await cloudAgentsApi.call('channelBindings', { slug }); + 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])); + setSelectedAccountId(current => (result.available_accounts ?? []).some(item => channelAccountId(item) === current) + ? current + : ''); + }, [slug]); + + useEffect(() => { + alive.current = true; + void refresh().catch(error => { if (alive.current) setLoadError(errorText(error)); }); + return () => { alive.current = false; }; + }, [refresh]); + + const loadRecovery = useCallback(async () => { + setRecoveryReady(false); + try { + const recovery = await cloudAgentsApi.recovery(); + if (!alive.current) return; + const channelOperations = new Set([ + 'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', + 'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart', + 'wechatBindVerification', 'revokeChannelCaller', 'retryChannelDeliveryPart', + ]); + setPendingRecovery(recovery.pending.filter(item => channelOperations.has(item.operation) && item.input.slug === slug)); + setRecoveryReady(true); + } catch (error) { + if (alive.current) setLoadError(errorText(error)); + setRecoveryReady(false); + } + }, [slug]); + + useEffect(() => { void loadRecovery(); }, [loadRecovery]); + + const refreshPanel = async () => { + setLoadError(''); + try { await Promise.all([refresh(), loadRecovery()]); } + catch (error) { setLoadError(errorText(error)); } + }; + + useEffect(() => { + if (bindingAccessMode) setAccessMode(bindingAccessMode === 'invited' ? 'invited' : 'self_only'); + }, [bindingAccessMode, currentId]); + + const runMutation = useCallback(async function ( + key: string, + operation: K, + input: CloudAgentOperations[K]['input'] & { operation_id: string; channel_account_id?: string }, + success?: string, + ): Promise { + if (busy) return null; + if (!recoveryReady) { + setNotice('正在确认上次渠道操作,请稍后再试'); + return null; + } + if (pendingRecovery.length > 0) { + setNotice('请先恢复或移除上次未确认的渠道操作'); + return null; + } + setBusy(true); + setBusyAction(key); + setLoadError(''); + setNotice(''); + try { + const result = await cloudAgentsApi.call(operation, input); + await refresh(); + if (alive.current && success) setNotice(success); + return result; + } catch (error) { + if (alive.current) setLoadError(errorText(error)); + await loadRecovery(); + return null; + } finally { + if (alive.current) { setBusy(false); setBusyAction(''); } + } + }, [busy, loadRecovery, pendingRecovery.length, recoveryReady, refresh]); + + const resolveRecovery = async (pending: CloudPendingOperation) => { + if (busy) return; + setBusy(true); + setBusyAction(`recovery:${pending.id}`); + setLoadError(''); + try { + const requestedAccountId = typeof pending.input.channel_account_id === 'string' ? pending.input.channel_account_id : ''; + if (requestedAccountId) { + const matching = bindings.find(item => channelAccountId(item) === requestedAccountId); + if (!matching) throw new Error('尚未读取到该操作的微信连接。请刷新状态后再次恢复,本机记录已保留。'); + if (requestedAccountId !== currentIdRef.current) { + selectBinding(requestedAccountId); + setTab('connect'); + setNotice(`已切换到${displayAccount(matching)},请再次点击恢复上次操作。`); + return; + } + } + const response = await cloudAgentsApi.resolvePending(pending.id, false); + const requestedGeneration = typeof pending.input.provider_generation === 'string' ? pending.input.provider_generation : ''; + const requestedBindingKey = requestedAccountId && requestedGeneration ? `${requestedAccountId}:${requestedGeneration}` : ''; + if ('requires_input' in response && response.requires_input === true) { + const sessionKey = typeof pending.input.session_key === 'string' ? pending.input.session_key : ''; + const matchesCurrent = (!requestedAccountId || requestedAccountId === currentIdRef.current) + && (!requestedBindingKey || requestedBindingKey === currentBindingKeyRef.current); + if (!sessionKey || !matchesCurrent) throw new Error('微信连接已变化,请刷新状态后重新确认。本机记录已保留。'); + setQrBindingKey(currentBindingKeyRef.current); + setQr({ session_key: sessionKey, status: 'verification_required', message: '请输入新的微信验证码' }); + setQrOpen(true); + setTab('connect'); + await cloudAgentsApi.resolvePending(pending.id, true); + setNotice('上次扫码需要新的验证码,请输入后重新提交'); + await loadRecovery(); + return; + } + if ('pending' in response && response.pending === true) { + await refresh(); + await loadRecovery(); + setNotice('上次渠道操作仍在处理中,请稍后再次恢复'); + return; + } + const payload = recoveryPayload(response.result); + const matchesCurrent = (!requestedAccountId || requestedAccountId === currentIdRef.current) + && (!requestedBindingKey || requestedBindingKey === currentBindingKeyRef.current); + if (isWechatBindState(payload) && matchesCurrent) { + setQrBindingKey(currentBindingKeyRef.current); + setQr(payload); + setQrOpen(true); + } else if (isPairing(payload) && matchesCurrent) { + setCreatedPairing(payload); + } + await refresh(); + await loadRecovery(); + setNotice('已恢复上次渠道操作,状态已经刷新'); + } catch (error) { + setLoadError(errorText(error)); + } finally { + if (alive.current) { setBusy(false); setBusyAction(''); } + } + }; + + const ignoreRecovery = async (pending: CloudPendingOperation) => { + if (busy) return; + setBusy(true); + setBusyAction(`ignore:${pending.id}`); + setLoadError(''); + try { + await cloudAgentsApi.resolvePending(pending.id, true); + await loadRecovery(); + setNotice('已移除本机未确认提醒,云端状态未被修改'); + } catch (error) { + setLoadError(errorText(error)); + } finally { + if (alive.current) { setBusy(false); setBusyAction(''); } + } + }; + + const startBinding = async () => { + if (!publishedVersion) return; + const input: CloudAgentOperations['createChannelBinding']['input'] = { + slug, + operation_id: operationId(), + display_name: '我的微信助手', + ...(selectedAccountId ? { channel_account_id: selectedAccountId } : {}), + }; + await runMutation('create-binding', 'createChannelBinding', input, '渠道已创建,下一步连接个人微信'); + setTab('connect'); + }; + + const startQr = async (force: boolean) => { + if (!binding) return; + const requestedAccountId = currentId; + const requestedBindingKey = currentBindingKey; + const key = `qr-start:${currentId}:${force ? 'force' : 'normal'}`; + const input: CloudAgentOperations['wechatBindStart']['input'] = { + slug, channel_account_id: currentId, force, + operation_id: operationId(), + }; + const result = await runMutation(key, 'wechatBindStart', input, '二维码已生成,请使用个人微信扫码'); + if (requestedAccountId === currentIdRef.current && requestedBindingKey === currentBindingKeyRef.current && isWechatBindState(result)) { + setQrBindingKey(requestedBindingKey); + setQr(result); + setQrOpen(true); + } + }; + + const refreshQr = useCallback(async (sessionKey: string) => { + if (!currentId || !sessionKey) return; + const requestedAccountId = currentId; + const requestedBindingKey = currentBindingKey; + try { + const result = await cloudAgentsApi.call('wechatBindStatus', { + slug, channel_account_id: currentId, session_key: sessionKey, + }); + const isCurrent = alive.current && requestedAccountId === currentIdRef.current && requestedBindingKey === currentBindingKeyRef.current; + if (isCurrent) setQr(current => current?.session_key === sessionKey ? result : current); + if (isCurrent && result.status === 'confirmed') { + setQrOpen(false); + await refresh(); + } + } catch (error) { + if (alive.current) setLoadError(errorText(error)); + } + }, [currentBindingKey, currentId, refresh, slug]); + + const sessionKey = activeQr?.session_key ?? ''; + const qrTerminal = isTerminalQr(activeQr); + useEffect(() => { + if (!activeView || tab !== 'connect' || !qrOpen || !currentId || !sessionKey || qrTerminal) return; + let cancelled = false; + let timer: number | undefined; + const poll = () => { + if (cancelled || document.visibilityState === 'hidden') return; + void refreshQr(sessionKey).finally(() => { + if (!cancelled && document.visibilityState === 'visible') timer = window.setTimeout(poll, 2000); + }); + }; + const onVisibility = () => { if (document.visibilityState === 'visible') poll(); }; + document.addEventListener('visibilitychange', onVisibility); + poll(); + return () => { + cancelled = true; + if (timer !== undefined) window.clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [activeView, currentId, qrOpen, qrTerminal, refreshQr, sessionKey, tab]); + + const verify = async () => { + if (!binding || !activeQr?.session_key || !verificationCode.trim()) return; + const requestedAccountId = currentId; + const requestedBindingKey = currentBindingKey; + const key = `qr-verify:${currentId}:${verificationCode.trim()}`; + const input: CloudAgentOperations['wechatBindVerification']['input'] = { + slug, channel_account_id: currentId, session_key: activeQr.session_key, + verify_code: verificationCode.trim(), operation_id: operationId(), + }; + const result = await runMutation(key, 'wechatBindVerification', input, '验证码已提交,正在确认登录状态'); + if (requestedAccountId === currentIdRef.current && requestedBindingKey === currentBindingKeyRef.current && isWechatBindState(result)) { + setQrBindingKey(requestedBindingKey); + setQr(result); + setVerificationCode(''); + setQrOpen(true); + } + }; + + const updateAccessMode = async () => { + if (!binding) return; + const key = `policy:${currentId}:${accessMode}`; + const input: CloudAgentOperations['channelPolicy']['input'] = { + slug, channel_account_id: currentId, access_mode: accessMode, + expected_policy_revision: binding.policy_revision ?? 0, + operation_id: operationId(), + }; + await runMutation(key, 'channelPolicy', input, + accessMode === 'self_only' ? '已切换为仅本人使用' : '已允许受邀联系人使用'); + await loadCallers(); + }; + + const issuePairing = async (kind: 'self' | 'invite') => { + if (!binding) return; + if (kind === 'invite' && binding.access_mode !== 'invited') { + setNotice('请先保存“本人及受邀联系人”,再生成邀请码'); + return; + } + const requestedAccountId = currentId; + const key = `pairing:${currentId}:${kind}`; + const input: CloudAgentOperations['createChannelPairing']['input'] = { + slug, channel_account_id: currentId, kind, + operation_id: operationId(), + }; + const result = await runMutation(key, 'createChannelPairing', input, + kind === 'self' ? '本人配对码已生成,请在目标微信对话中发送' : '邀请码已生成,请手动发给受邀联系人'); + if (requestedAccountId === currentIdRef.current && isPairing(result)) setCreatedPairing(result); + }; + + const loadCallers = useCallback(async () => { + if (!currentId) return; + const requestedAccountId = currentId; + const requestedBindingKey = currentBindingKey; + try { + const result = await cloudAgentsApi.call('channelCallers', { slug, channel_account_id: currentId }); + if (alive.current && requestedAccountId === currentIdRef.current && requestedBindingKey === currentBindingKeyRef.current) setCallers(result.items); + } catch (error) { + if (alive.current) setLoadError(errorText(error)); + } + }, [currentBindingKey, currentId, slug]); + + useEffect(() => { if (tab === 'access') void loadCallers(); }, [loadCallers, tab]); + + const loadActivity = useCallback(async (offset = 0) => { + if (!currentId) return; + const requestedAccountId = currentId; + const requestedBindingKey = currentBindingKey; + try { + const result = await cloudAgentsApi.call('channelActivity', { slug, channel_account_id: currentId, offset, limit: 50 }); + if (!alive.current || requestedAccountId !== currentIdRef.current || requestedBindingKey !== currentBindingKeyRef.current) return; + setActivity(current => offset ? [...current, ...result.items] : result.items); + setActivityOffset(result.next_offset); + setActivityRevision(value => value + 1); + } catch (error) { + if (alive.current) setLoadError(errorText(error)); + } + }, [currentBindingKey, currentId, slug]); + + useEffect(() => { if (tab === 'activity') void loadActivity(); }, [loadActivity, tab]); + + const retryPart = async (partId: string, messageId: string) => { + if (!currentId) return; + if (!recoveryReady) { + setNotice('正在确认上次渠道操作,请稍后再试'); + return; + } + if (pendingRecovery.length > 0) { + setNotice('请先恢复或移除上次未确认的渠道操作'); + return; + } + try { + await cloudAgentsApi.call('retryChannelDeliveryPart', { + slug, channel_account_id: currentId, logical_message_id: messageId, part_id: partId, + }); + setNotice('已提交文件补发'); + await loadActivity(); + } catch (error) { + setLoadError(errorText(error)); + await loadRecovery(); + }; + }; + + const revoke = async (caller: CloudChannelCaller) => { + if (!binding) return; + const key = `revoke:${caller.caller_id}`; + const input: CloudAgentOperations['revokeChannelCaller']['input'] = { + slug, channel_account_id: currentId, caller_id: caller.caller_id, + operation_id: operationId(), + }; + await runMutation(key, 'revokeChannelCaller', input, '已撤销此调用者'); + await loadCallers(); + }; + + const stateAction = async (enabled: boolean) => { + if (!binding) return; + const key = `state:${currentId}:${enabled ? 'enable' : 'pause'}`; + if (enabled) { + const input: CloudAgentOperations['enableChannelBinding']['input'] = { + slug, channel_account_id: currentId, + expected_revision: binding.revision ?? binding.route_revision, + operation_id: operationId(), + }; + await runMutation(key, 'enableChannelBinding', input, '微信渠道已启用'); + } else { + const input: CloudAgentOperations['pauseChannelBinding']['input'] = { + slug, channel_account_id: currentId, + expected_revision: binding.revision ?? binding.route_revision, + operation_id: operationId(), + }; + await runMutation(key, 'pauseChannelBinding', input, '微信渠道已暂停'); + } + setConfirmEnable(false); + }; + + const switchRoute = async () => { + if (!binding || !routeTargetSlug.trim()) return; + const key = `route:${currentId}:${routeTargetSlug.trim()}`; + const input: CloudAgentOperations['switchChannelBindingAgent']['input'] = { + slug, channel_account_id: currentId, target_agent_slug: routeTargetSlug.trim(), + expected_revision: binding.revision ?? binding.route_revision, enabled: binding.enabled, + operation_id: operationId(), + }; + const result = await runMutation(key, 'switchChannelBindingAgent', input, '渠道已切换到目标智能体'); + if (result) setRouteTargetSlug(''); + }; + + const openRoutePicker = async () => { + try { + const agents: CloudAgentDraft[] = []; + let cursor: string | null = null; + do { + const page = await cloudAgentsApi.list(cursor); + agents.push(...page.agents); + cursor = page.next_cursor; + } while (cursor); + setRouteAgents(agents.filter(agent => agent.slug !== slug && agent.published_version !== null)); + setRoutePickerOpen(true); + } catch (error) { + setLoadError(errorText(error)); + } + }; + + const unbind = async () => { + if (!binding) return; + const key = `unbind:${currentId}`; + const input: CloudAgentOperations['disconnectChannelBinding']['input'] = { + slug, channel_account_id: currentId, + expected_revision: binding.revision ?? binding.route_revision, + operation_id: operationId(), + }; + await runMutation(key, 'disconnectChannelBinding', input, '微信已解绑,渠道保留历史活动'); + setConfirmUnbind(false); setQr(null); setQrOpen(false); + }; + + usePendingCloudInput(busy || Boolean(verificationCode) || Boolean(createdPairing)); + + const connected = isConnected(binding); + const enabled = binding?.desired_state === 'enabled' || binding?.enabled === true; + const canEnable = Boolean(binding && publishedVersion && connected); + + return
+
+

发布到个人微信

+

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

+ +
+
+ + + + {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 startBinding()} onStartQr={() => void startQr(false)} onRestartQr={() => void startQr(true)} onCloseQr={() => setQrOpen(false)} + onVerificationCode={setVerificationCode} onVerify={() => 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()} onPair={kind => void issuePairing(kind)} onClearPairing={() => setCreatedPairing(null)} onRevoke={caller => void revoke(caller)} />} + {tab === 'activity' && void loadActivity()} onMore={() => void (activityOffset == null ? undefined : loadActivity(activityOffset))} onRetry={retryPart} />} + {tab === 'sessions' && } +
; +} + +function StatusPill({ label, tone }: { label: string; tone: 'green' | 'amber' | 'gray' }) { + const color = tone === 'green' ? 'bg-emerald-100 text-emerald-800' : tone === 'amber' ? 'bg-amber-100 text-amber-800' : 'bg-muted text-muted-foreground'; + return {label}; +} + +function ConnectStep({ + slug, binding, bindings, accounts, selectedBindingId, selectedAccountId, busy, busyAction, publishedVersion, qr, qrOpen, verificationCode, + confirmEnable, confirmUnbind, canEnable, routeTargetSlug, routeAgents, routePickerOpen, onSelectBinding, onSelectAccount, onCreate, onStartQr, onRestartQr, onCloseQr, + onVerificationCode, onVerify, onEnable, onPause, onRouteTarget, onOpenRoute, onSwitchRoute, onConfirmEnable, onUnbind, onConfirmUnbind, +}: { + slug: string; binding: CloudChannelView | null; bindings: CloudChannelView[]; accounts: CloudChannelAccountView[]; selectedBindingId: string; selectedAccountId: string; busy: boolean; busyAction: string; + publishedVersion: number | null; qr: CloudWechatBindState | null; qrOpen: boolean; verificationCode: string; confirmEnable: boolean; confirmUnbind: boolean; canEnable: boolean; routeTargetSlug: string; routeAgents: CloudAgentDraft[]; routePickerOpen: boolean; + onSelectBinding: (value: string) => void; onSelectAccount: (value: string) => void; onCreate: () => void; onStartQr: () => void; onRestartQr: () => void; onCloseQr: () => void; + onVerificationCode: (value: string) => void; onVerify: () => void; onEnable: () => void; onPause: () => void; onRouteTarget: (value: string) => void; onOpenRoute: () => void; onSwitchRoute: () => void; onConfirmEnable: (value: boolean) => void; + onUnbind: () => void; onConfirmUnbind: (value: boolean) => void; +}) { + const id = channelAccountId(binding); + const connected = isConnected(binding); + const available = accounts.filter(account => account.adoption_state === 'available'); + const occupied = accounts.filter(account => account.adoption_state && account.adoption_state !== 'available'); + return
+ + {!binding &&
{occupied.length > 0 &&

已有连接正在其他智能体或外部渠道使用。请在原智能体的渠道面板完成切换后再接入,不会在这里重复占用。

}{!publishedVersion &&

请先发布一个版本,再连接微信。

}
} + {bindings.length > 1 && } + {binding &&
+

{displayAccount(binding)}

+ {binding.blockers?.length ?

完成启用前还需要:{binding.blockers.map(blockerLabel).join('、')}

: null} + {!connected && !qrOpen && } + {qrOpen && qr && } + {connected && !qrOpen &&
{binding.desired_state === 'enabled' || binding.enabled ? : }
} + {confirmEnable && binding && onConfirmEnable(false)} />} + {confirmUnbind &&

确认解绑这个个人微信?

尚未发送的渠道交付会停止,历史活动保留;微信登录状态会被撤销。

} + {connected &&

切换到另一个智能体

切换会先停止当前渠道的投递,再把这个微信账号路由到目标智能体。需要明确选择并确认。

{!routePickerOpen && }{routePickerOpen &&
{!routeAgents.length &&

没有其他已发布的个人智能体可切换。

}
}
} +
} + +
; +} + +function QrCard({ qr, verificationCode, busy, onClose, onRestart, onVerificationCode, onVerify }: { qr: CloudWechatBindState; verificationCode: string; busy: boolean; onClose: () => void; onRestart: () => void; onVerificationCode: (value: string) => void; onVerify: () => void }) { + return
{qr.qrcode_url ? : }

{statusLabel(qr.status)}

二维码只在当前页面轮询,关闭页面不会重复扫码。

{qr.message &&

{qr.message}

}
{qr.status === 'verification_required' &&
}
扫码成功后会自动确认连接
; +} + +function StepHeading({ number, title, description }: { number: string; title: string; description: string }) { + return
{number}

{title}

{description}

; +} + +function AccessStep({ binding, accessMode, callers, pairing, busy, busyAction, onMode, onSave, onPair, 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; onPair: (kind: 'self' | 'invite') => void; onClearPairing: () => void; onRevoke: (caller: CloudChannelCaller) => void }) { + const inviteEnabled = accessMode === 'invited' && binding?.access_mode === 'invited'; + return
+ {!binding ?

请先完成微信连接。

: <> +
+

本人配对

生成一次性配对码后,在你的目标微信私聊中发送完整配对命令。未完成配对前不会调用模型。

{accessMode === 'invited' &&

邀请联系人

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

{!inviteEnabled &&

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

}
}
+ {pairing && } +

已授权调用者

{callers.length} 个
{!callers.length &&

还没有已配对的调用者。

}{callers.map(caller =>

{caller.access_mode === 'self_only' ? '本人微信会话' : '受邀联系人'}

{statusLabel(caller.grant_state)} · 配对于 {shortDate(caller.created_at)}

)}
+ }
; +} + +function PairingResult({ value, onClear }: { value: CloudChannelPairing; onClear: () => void }) { + const copy = () => { if (value.code) void navigator.clipboard.writeText(`${value.kind === 'self' ? '配对' : '邀请'} ${value.code}`); }; + return

{value.kind === 'self' ? '本人一次性配对码' : '一次性邀请码'}

{value.expires_at ? shortDate(value.expires_at) : '短期有效'};关闭此面板后不会再次展示。

{value.code ?
{value.code}
:

服务已受理,请在活动记录中查看操作状态。

}
; +} + +function ActivityStep({ slug, channelAccountId, refreshKey, activity, nextOffset, busy, onRefresh, onMore, onRetry }: { + slug: string; channelAccountId: string; refreshKey: number; activity: CloudChannelActivity[]; nextOffset: number | null; busy: boolean; + onRefresh: () => void; onMore: () => void; onRetry: (partId: string, messageId: string) => void; +}) { + return

活动记录

显示执行阶段、费用和微信投递阶段;受邀者的提问标题与正文不会出现在这里。

{!activity.length &&

还没有渠道活动。

}{activity.map((item, index) => )}{nextOffset != null && }
; +} + +function ActivityItem({ slug, channelAccountId, refreshKey, item, busy, onRetry }: { + slug: string; channelAccountId: string; refreshKey: number; item: CloudChannelActivity; busy: boolean; + onRetry: (partId: string, messageId: string) => void; +}) { + const [delivery, setDelivery] = useState(null); + const [deliveryError, setDeliveryError] = useState(''); + const messageId = typeof item.delivery?.result?.message_id === 'string' ? item.delivery.result.message_id : ''; + useEffect(() => { + if (!messageId || !channelAccountId) return; + let live = true; + void cloudAgentsApi.call('channelDelivery', { slug, channel_account_id: channelAccountId, logical_message_id: messageId }) + .then(result => { if (live) { setDelivery(result); setDeliveryError(''); } }) + .catch(error => { if (live) setDeliveryError(errorText(error)); }); + return () => { live = false; }; + }, [channelAccountId, messageId, refreshKey, slug]); + const failedFiles = delivery?.parts.filter(part => part.type === 'file' && part.status === 'failed') ?? []; + const tokenUsage = tokenUsageLabel(item.token_usage); + const deliveryStatus = deliveryError ? 'unknown' : delivery?.status ?? item.delivery?.core?.provider_status + ?? item.delivery?.core?.delivery_status ?? item.delivery?.core?.adapter_status; + return
+
{statusLabel(item.status)}
+ {shortDate(item.timing?.created_at ?? item.created_at ?? item.started_at)}
+
执行:{statusLabel(item.status)}微信投递:{deliveryLabel(deliveryStatus)}{tokenUsage && {tokenUsage}}
+ {item.input_preparation &&

输入准备:{statusLabel(item.input_preparation.state)}

} + {deliveryStatus === 'waiting_context' &&

请在对应微信对话发送新消息,恢复可用的回复窗口。

} + {deliveryError &&

文件状态暂时无法读取,请稍后刷新。

} + {failedFiles.map(part =>
文件分片 · {statusLabel(part.status)}
)} +
; +} diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx index 5b0f1ab..ba7c331 100644 --- a/src/pages/CloudAgents/CloudChat.tsx +++ b/src/pages/CloudAgents/CloudChat.tsx @@ -322,7 +322,10 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
; } -function CloudApproval({ interrupt, runId, onResumed }: { interrupt: CloudInterrupt; runId: string; onResumed: (run: CloudRun) => void }) { +export function CloudApproval({ interrupt, runId, onResumed, submitDecision }: { + interrupt: CloudInterrupt; runId: string; onResumed?: (run: CloudRun) => void; + submitDecision?: (input: { operation_id: string; decision: Record }) => Promise; +}) { const [answers, setAnswers] = useState>({}); const [freeText, setFreeText] = useState>({}); const [error, setError] = useState(''); @@ -344,8 +347,11 @@ function CloudApproval({ interrupt, runId, onResumed }: { interrupt: CloudInterr const input = intent ?? { operation_id: crypto.randomUUID(), decision }; setIntent(input); setBusy(true); setError(''); try { - const result = await cloudAgentsApi.call('resume', { run_id: runId, ...input }); - onResumed(await cloudAgentsApi.call('run', { run_id: result.run_id })); + if (submitDecision) await submitDecision(input); + else { + const result = await cloudAgentsApi.call('resume', { run_id: runId, ...input }); + onResumed?.(await cloudAgentsApi.call('run', { run_id: result.run_id })); + } } catch(e) { setError(message(e)); } finally { setBusy(false); } }; diff --git a/src/pages/CloudAgents/CloudCosts.tsx b/src/pages/CloudAgents/CloudCosts.tsx index 3a7de4f..340da2f 100644 --- a/src/pages/CloudAgents/CloudCosts.tsx +++ b/src/pages/CloudAgents/CloudCosts.tsx @@ -6,7 +6,7 @@ import type { CloudApplication, CloudBudget, CloudCostPage, CloudCostSource } fr import { usePendingCloudInput } from './CloudPending'; const message = (e: unknown) => e instanceof Error ? e.message : '费用服务暂不可用'; -const sources: Record = { self: '自己使用', shared: '分享', api: 'API', scheduled: '自动任务', preview: '草稿预览', knowledge: '知识库处理' }; +const sources: Record = { self: '自己使用', shared: '分享', api: 'API', scheduled: '自动任务', preview: '草稿预览', knowledge: '知识库处理', channel: '微信渠道' }; export function CloudBudgetEditor({ slug }: { slug: string }) { const [budget, setBudget] = useState(null); @@ -24,7 +24,7 @@ export function CloudBudgetEditor({ slug }: { slug: string }) { }, [slug, accept]); return

费用上限

-

这个智能体的自用、分享、API 和自动任务共同计入。单次任务包含多轮模型调用及子智能体;知识库处理也计入费用。留空表示不设额外上限,填写 0 可暂停新增模型调用。

+

这个智能体的自用、分享、API、微信渠道和自动任务共同计入。单次任务包含多轮模型调用及子智能体;知识库处理也计入费用。留空表示不设额外上限,填写 0 可暂停新增模型调用。

{ e.preventDefault(); setBusy(true); setError(''); try { accept(await cloudAgentsApi.call('saveBudget', { slug, request_limit_points: requestLimit.trim() || null, daily_limit_points: dailyLimit.trim() || null })); } diff --git a/src/pages/CloudAgents/CloudRecovery.tsx b/src/pages/CloudAgents/CloudRecovery.tsx index a9c3f1b..9d1ed69 100644 --- a/src/pages/CloudAgents/CloudRecovery.tsx +++ b/src/pages/CloudAgents/CloudRecovery.tsx @@ -8,22 +8,27 @@ const labels: Record = { createApplication: '创建应用', createKey: '创建密钥', createKnowledge: '创建知识库', processKnowledge: '处理文档', createSchedule: '创建自动任务', runSchedule: '立即运行任务', importKnowledgeAttachment: '导入附件到知识库', createChild: '创建子智能体', + wechatBindVerification: '微信连接验证', }; export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) { const [busy, setBusy] = useState(''); const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); const [result, setResult] = useState<{ operation: string; value: unknown } | null>(null); const resolve = async (item: CloudPendingOperation, discard = false) => { - setBusy(item.id); setError(''); + setBusy(item.id); setError(''); setNotice(''); try { const response = await cloudAgentsApi.resolvePending(item.id, discard); - if (!discard) setResult({ operation: item.operation, value: response.result }); + if (response.requires_input) { + setResult(null); + setNotice('这次微信验证还需要验证码。请进入对应智能体的「发布与访问 → 个人微信」,继续连接验证。'); + } else if (!discard) setResult({ operation: item.operation, value: response.result }); onResolved(); } catch (e) { setError(e instanceof Error ? e.message : '暂时无法确认结果'); } finally { setBusy(''); } }; - if (!pending.length && !result && !error) return null; + if (!pending.length && !result && !error && !notice) return null; return ; } diff --git a/src/pages/CloudAgents/CloudSchedules.tsx b/src/pages/CloudAgents/CloudSchedules.tsx index ca9b251..8151021 100644 --- a/src/pages/CloudAgents/CloudSchedules.tsx +++ b/src/pages/CloudAgents/CloudSchedules.tsx @@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; -import type { CloudSchedule, CloudScheduleInput, CloudThread, CloudScheduleProposal, CloudScheduleContext, CloudBudget } from '../../../shared/cloud-agents'; +import type { CloudSchedule, CloudScheduleInput, CloudThread, CloudScheduleProposal, CloudScheduleContext, CloudBudget, CloudAgentOperations } from '../../../shared/cloud-agents'; import { cloudStatus } from './CloudChat'; import { usePendingCloudInput } from './CloudPending'; @@ -41,6 +41,7 @@ export function CloudSchedules({ slug, onOpen, proposal, onProposalClosed }: { s

{job.name}

{job.prompt}

{job.enabled ? '已启用' : '已暂停'}

{scheduleLabel(job.cron_expression)} · {job.timezone} · 下次 {job.enabled && job.next_run_at ? new Date(job.next_run_at).toLocaleString() : '—'}

+

{job.result_notification?.enabled ? '结果同时发送到指定的本人微信对话' : '结果保存在客户端任务历史'}

} {job.runs?.map((run, i) =>
-

{cloudStatus(run.status)}{run.error_message && {run.error_message}}

+

{cloudStatus(run.status)}{run.error_message && {run.error_message}}

+ {run.result_notification &&

{run.result_notification.state === 'waiting_context' + ? '微信通知待恢复:请确认连接,并在已配对的微信对话中发送一条消息。任务结果已独立保存。' + : '微信通知已配置,实际送达情况可在发布渠道的活动记录中查看。'}

}
{run.conversation_available && }
)} @@ -80,6 +84,14 @@ function ScheduleEditor({ slug, initial, proposal, onClose, onSaved }: { slug: s const save = async () => { if (busy) return; const input = intent ?? { ...value, operation_id: crypto.randomUUID() }; + // Omission preserves an existing target even when a disconnected channel + // cannot currently pass notification admission. Changing/enabling validates it. + if (!intent && initial && !input.enabled + && input.result_notification?.enabled === initial.result_notification?.enabled + && input.result_notification?.caller_id === initial.result_notification?.caller_id + && input.result_notification?.channel_account_id === initial.result_notification?.channel_account_id) { + delete input.result_notification; + } setIntent(input); setBusy(true); setError(''); try { if (initial) await cloudAgentsApi.call('updateSchedule', { slug, job_id: initial.id, ...input }); @@ -94,6 +106,7 @@ function ScheduleEditor({ slug, initial, proposal, onClose, onSaved }: { slug: s