Compare commits

...
2 Commits
Author SHA1 Message Date
brother7 46e7f6f1cd docs(agents): 记录微信本人免配对主分支集成
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
2026-09-13 23:26:41 +08:00
brother7 8c1b161d0e fix(cloud-agents): 移除微信本人配对入口 2026-09-13 23:24:08 +08:00
12 changed files with 148 additions and 40 deletions

No files matched your search

@@ -27,16 +27,18 @@ Accepted / implemented,2026-09-11 集成。产品范围、三端分工、创
## Personal WeChat Publication(2026-09-13 accepted / implemented)
- Published cloud agents expose a native personal WeChat panel: create/adopt account, local QR rendering, verification, self pairing/invitations, access revocation, enable/pause, route switch, disconnect, safe activity and failed-file retry.
- Published cloud agents expose a native personal WeChat panel: create/adopt account, local QR rendering, login verification, direct use by the scanning owner, contact invitations, access revocation, enable/pause, route switch, disconnect, safe activity and failed-file retry. Creator self-pairing codes are removed, including stale recovery presentation; invited contacts still receive a complete invitation command.
- Renderer uses typed Host operations. Main owns credentials, network, per-account durable original operations and file saving. Verification codes never enter the recovery journal; uncertain mutations require explicit recovery.
- QR payloads are encoded locally. Account or Provider-generation changes clear transient state. Enable confirmation shows the actual published version, audience and task/daily budget, including zero.
- Own WeChat conversations use dedicated Yuxi session endpoints for approval, stop/new session, complete history pagination and automatically refreshed deliverables. The creator cannot browse invited callers' content.
- Scheduled result notification requires an explicit paired self target. Execution completion and WeChat delivery status remain distinct; retrying a failed file part does not run the model again.
- The scanning owner's first message establishes their history and available notification conversation. Scheduled result notification requires an explicitly selected self target. Execution completion and WeChat delivery status remain distinct; retrying a failed file part does not run the model again.
Source `2e710db0fb512f276fda8ae8a3939d366b34a972`, [source task](../30-worklog/tasks/20260913-agentbus-channels-ml-3ec75b90.md). Final 81 focused tests, typecheck, production build and 2 Electron scenarios passed, with independent review PASS. Tests use local Host fixtures; real WeChat, billing, packaging and deployment acceptance remain pending.
## Evidence And Limits
The creator no-code change explicitly replaces the earlier pairing step. See [reviewed source task](../30-worklog/tasks/20260913-wechat-auto-self-ml-3679bce2.md): 64 focused tests, typecheck, build, scoped lint and an inspected Electron scenario passed; independent review passed. Main remains the network boundary and Yuxi/AgentBus establish identity. Compatible backend and client deployment is required; historical connections missing scanning identity need one reconnect. No real WeChat acceptance or installer was produced by this change.
源:WS `0f1fb8af`、Yuxi `1df0142`、MakeLore `303f262`;本仓库见 [源任务](../30-worklog/tasks/20260910-personal-agent-platform-d15b2559.md)。本批独立 Reviewer 最终 PASS。源验证包含 WS 113 passed / 1 skipped 和预算 PG 4 passed,Yuxi 1985 non-slow unit / 19 HTTP-存储 integration 及归档最后补丁 3 integration,MakeLore 39 unit / Renderer tsc / build / Electron 1 passed。模型授权/供应商及沙盒释放有明确替身;MakeLore Main 全仓保留 61 项既有类型诊断。
真实收费模型对账、完整服务/ARQ/provisioner 与重启、安装包/协议/跨设备、生产迁移和部署仍待验收。并行 OSS 替换与远程 Milvus 部署由各任务集成和验收;本决策不接受其尚未合入的存储实现。
@@ -2,6 +2,8 @@
## Current Architecture
扫码本人免配对:微信 Adapter 从持久登录状态读取扫码者身份并匹配原始发送者,Core 经绑定、代次、Worker 和会话范围校验后提供 `is_self`,Yuxi 才建立本人 caller/session。客户端不声明本人身份;邀请、撤销和调用者内容隔离保持原规则。历史空身份连接重新扫码后会刷新运行实例,无需推进账号代次。
个人微信云智能体已按 [已接受渠道决策](../10-decisions/ADR-2026-09-11-personal-cloud-agents.md) 集成:AgentBus 拥有共享身份下的 Worker/ChannelAccount 路由及交付,Yuxi 拥有独立调用者会话和原生执行,Works Square 拥有创建者词元点数,MakeLore Main 拥有桌面授权与恢复。沿用标准 Worker,不增加独立 Adapter 池;付款不授予受邀调用者内容权限。上线验收与本地代码集成分开记录。
个人云智能体职责见 [已接受决策](../10-decisions/ADR-2026-09-11-personal-cloud-agents.md):Yuxi 拥有云执行,WS 拥有账号和词元点数,MakeLore Main 提供 Host API;Code/Pi、Canvas、Robot 保持既有体系。
@@ -4,6 +4,8 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
## Integrated Through
- 2026-09-13:任务 `20260913-merge-wechat-self-ml-a904d7c1` 将已审查源 `8c1b161d0e14394beb62ae7f7e05c96cfed045b2` 无冲突快进合入本地 `main`。个人微信发布页移除本人配对入口,扫码本人直接使用;首条消息建立本人历史和通知目标,其他联系人仍通过独立邀请接入。源任务记录原样导入,独立 Reviewer 最终 PASS,验证与线上限制见[源任务](tasks/20260913-wechat-auto-self-ml-3679bce2.md)。原有 3 份未跟踪文档保持不变;未推送、部署或清理工作区。
- 2026-09-13:任务 `20260913-merge-agentbus-channels-ml-c4e81f2a` 经用户授权移交主目录后,将渠道源 `2e710db0fb512f276fda8ae8a3939d366b34a972` 从 `03774d0625a5a0d0221dbb9989e88a9406e0fa48` 无冲突快进合入本地 `main`。产品代码与已审查、测试的源完全一致,源任务记录原样导入;个人微信、共享 SSO 与按用户隔离规则见 [渠道决策](../10-decisions/ADR-2026-09-11-personal-cloud-agents.md)。原有 3 份文档改动原样保留;未推送或部署。授权清理的源工作区因 Git 目录非空错误部分移除,残余保留,分支和提交完整。
- 2026-09-13:续接任务 `20260912-land-model-capabilities-c6fa4309`,将图片附件修复源 `03fc50f7a9009b8e5c43fada8bc3365103dc732c` 从 `1d661f7` 无冲突快进合入本地 main。发送时、Pi 实时消息与历史恢复均保留图片引用,重复投影复用附件文件。产品树与已验证源完全相同,复用其相关回归、真实 Pi 新会话/恢复会话 HTTP、Electron 图片刷新、类型检查与构建证据。三个原有未跟踪记录保持原样;工作区清理等待单独确认,未推送、打包或安装。详见[图片流转](../20-architecture/data-flow.md#code-image-attachment-projection)。
@@ -0,0 +1,40 @@
# Task: 合并微信本人免配对改动到主分支
## Identity
- Task ID: 20260913-merge-wechat-self-ml-a904d7c1
- Mode: Integration
- Branch: main
- Worktree: D:\Datas\OthersProjects\makelore
- Base commit: 4ca1f89a7e2d1a2c7e90af3b4aeb3541f3f0cd4f
- Owner: codex
- Status: Ready for Integration
## Scope
- Integrate the reviewed creator WeChat auto-self source into the local main branch and promote its accepted identity/UI contract. Preserve the existing foreign documents and all source/review worktrees.
## Intent And Constraints
- Official Concurrent/Planning Gates Passed after explicit user authorization for one fresh read-only Reviewer and all three primary ownership handoffs. Existing foreign document states are adopted unchanged through the official scripts. Read active/startup/current/channel sources and source outcomes; unchanged peer contexts reused, fresh review peers added. Historical placeholders remain unknown/read-only with no concrete semantic dependency.
- User explicitly removes creator pairing codes while preserving separate invited callers and privacy. Source task records are imported unchanged after commit; this Integration task owns canonical promotion.
- Plan: finish independent review, remedy any supported findings in source-owned tasks, commit final sources, fast-forward main branches, promote bounded canonical changes, verify ancestry/product equivalence and documentation drift, then record completion. No push, deployment or directory removal.
## Outcome
- Integrated source `8c1b161d0e14394beb62ae7f7e05c96cfed045b2` by conflict-free fast-forward onto local `main`. Product files and the imported source task record remain identical to the reviewed source. Promoted the creator no-code identity contract to the accepted channel decision, architecture and current-state.
- The single user-authorized fresh Reviewer returned final PASS after the AgentBus active legacy-reconnect finding was fixed. Existing 3 unrelated untracked documents remain outside this commit; source and review worktrees are retained. No push, deployment, packaging or paid requests.
## Verification
- Source ancestry and exact product/imported-record equivalence passed after fast-forward; only the three named canonical documents and this Integration record are changed. Git diff whitespace checks passed. Reusing final source checks because integration changed no product code.
- Source evidence: 64 focused tests, typecheck, scoped lint, production build and one inspected Electron fixture scenario passed; independent panel check passed 15 cases.
- Read the fresh review tasks' Scope/Intent/Promotion; no outstanding semantic conflict. Official documentation drift and completion gates are required before final response and are recorded by their command results.
## Follow-ups
- Deploy compatible AgentBus Core/WeChat Adapter and Yuxi API/Worker, and distribute an updated MakeLore client separately. Historical connections without stored scanning identity need one reconnect. No new environment variables or schema migration. Real WeChat delivery remains an external acceptance step.
## Promotion Candidates
- Promoted the source task's accepted creator no-code identity/UI decision, replacing the prior self-pairing requirement at the user's explicit request. Existing invitations, revocation, caller privacy and creator billing remain. No candidates deferred.
@@ -0,0 +1,42 @@
# Task: 移除微信本人配对界面并展示绑定身份状态
## Identity
- Task ID: 20260913-wechat-auto-self-ml-3679bce2
- Mode: Feature
- Branch: codex/20260913-wechat-auto-self-ml-3679bce2-wechat-auto-self-ml
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260913-wechat-auto-self-ml-3679bce2
- Base commit: 4ca1f89a7e2d1a2c7e90af3b4aeb3541f3f0cd4f
- Owner: codex
- Status: Ready for Integration
## Scope
- Remove creator self pairing from personal WeChat publication. Explain automatic recognition of the QR login owner, retain explicit invitations for other contacts, and update conversation/schedule guidance, focused regressions and README.
## Intent And Constraints
- Resumed for the user-authorized main-branch merge. User explicitly confirmed one fresh read-only Reviewer and three primary registration handoffs. Source Concurrent/Planning Gates Passed on unchanged bases; only this feature task may record its remediation and commit evidence. Independent review is in progress.
- Concurrent and Planning Gates Passed using official start/status and loaded entry, task, current-state, personal cloud ADR, architecture and peer records. Historical placeholder peer context remains unknown/read-only without a concrete semantic dependency. Related channel peers are completed integration or read-only diagnosis.
- User explicitly replaces creator self-code behavior. Main remains the credential/network boundary; remote services determine owner identity. Preserve caller isolation, paid execution authorization, existing access revocation and invited contacts. No production actions, pushes, primary checkout edits or canonical memory writes.
- Plan: remove self-code actions and stale recovery presentation; update only meaningful guidance/error projections; verify invitations still work, first-use UI has no pairing action, and Electron publication/conversation flows remain usable.
## Outcome
- Removed the self-code action/card and old-self-code recovery display. Bound scanning WeChat can directly send messages; only invited contacts have an invite-code action. Conversation/history and scheduled target empty states explain sending the first message rather than pairing. Updated Main safe error guidance and README; existing privacy/revocation/approval and invitation transport remain.
- Source implementation is complete and ready for authorized local integration. The user-authorized fresh independent Reviewer returned final PASS. No packaging, deployment or paid requests were performed.
## Verification
- Final independent review: PASS; review task `20260913-wechat-self-review-ml-a85f20` is complete in its own retained read-only worktree. No remaining findings. Product validation evidence below remains applicable; no live WeChat or production acceptance is claimed.
- Pinned pnpm 10.33.4 install --frozen-lockfile passed. Focused panel/conversation/enable/workflow/Main suites: 64 passed, including absence of self pairing calls, explicit invitation command copy, binding switch and existing delivery behavior.
- pnpm exec tsc --noEmit -p tsconfig.json, pnpm run build:vite and scoped ESLint passed. git diff --check passed.
- Electron cloud-agent-channels.spec.ts: 1 passed, covering no self pairing action, QR/verification, owner approval/history/file delivery and notification target. Inspected wechat-access-without-pairing.png from the actual Electron test. Host API is fixture-backed; no live WeChat/model acceptance or new installer was performed.
## Follow-ups
- Integrate with matching AgentBus and Yuxi sources. Deployment and client packaging remain separate. A historical WeChat account without stored scanning identity needs one reconnect.
## Promotion Candidates
- Target: accepted personal cloud Agent ADR, current-state and channel architecture. Proposal: replace creator self pairing steps with automatic recognition of the scanning WeChat; first incoming owner message establishes history/notification session. Keep separate contact invitations and privacy. Evidence: focused unit, typecheck/build/lint and inspected Electron flow. Conflict: user explicitly removes previous self-code UX; canonical update belongs to integration, no further product confirmation.
+1 -1
View File
@@ -11,7 +11,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
云知识库的向量模型由平台管理员配置:Yuxi 后台「智能体管理 → 模型供应商」启用 embedding 模型并填写实际维度;Works Square 运营后台「模型管理」从 one-api 刷新并激活相同模型 ID。one-api 必须支持该模型的向量接口。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
在「发布与访问」中可通过 AgentBus 连接个人微信:选用本人已有连接或扫码连接新微信,完成本人配对、设置联系人使用权限,再确认启用。渠道支持暂停、切换智能体、断开连接和查看运行/投递活动;失败的文件可单独补发。本人已配对的微信会话可在客户端查看、审批、停止、新开一段并保存实际产物;受邀联系人的内容不会因创建者付费而对创建者开放。自动任务可明确选择把结果发送到已配对的本人微信,暂停保留目标;费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录。
在「发布与访问」中可通过 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 密码。
+5 -4
View File
@@ -45,7 +45,8 @@ const MESSAGES: Record<string, string> = {
channel_session_control_failed: '微信会话操作失败,请刷新后重试',
channel_delivery_not_found: '渠道交付记录不存在',
channel_delivery_retry_failed: '文件补发失败,请刷新交付记录后重试',
pairing_expired: '配对码已过期,请重新生成',
self_pairing_removed: '本人无需配对,请直接使用绑定时扫码的微信发送消息',
pairing_expired: '邀请码已过期,请重新生成',
verification_required: '请先完成微信验证',
artifact_unavailable: '文件已不可用,请刷新文件列表',
operation_unavailable: '操作状态暂时无法确认,请稍后查询',
@@ -61,9 +62,9 @@ const MESSAGES: Record<string, string> = {
run_not_active: '当前会话没有可操作的运行',
channel_session_unavailable: '新的微信会话暂时无法建立',
pairing_invalid: '配对请求无效,请刷新后重试',
invitation_invalid: '配对码无效或已撤销',
invitation_consumed: '配对码已经使用',
invites_disabled: '当前渠道仅允许创建者本人配对',
invitation_invalid: '邀请码无效或已撤销',
invitation_consumed: '邀请码已经使用',
invites_disabled: '当前渠道仅允许本人使用',
agentbus_unavailable: '渠道服务暂时不可用,请稍后重试',
};
@@ -50,11 +50,11 @@ export function CloudChannelConversations({ slug, activeView = true }: { slug: s
};
return <section aria-label="本人微信会话" className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-medium">本人微信对话</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">查看你已配对的本人会话,处理确认和保存文件。新消息请从对应微信对话发送。</p></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">查看你的本人微信会话,处理确认和保存文件。新消息请从对应微信对话发送。</p></div>
<Button variant="ghost" size="sm" disabled={loading || busy} onClick={() => setRevision(value => value + 1)}><RefreshCw className="mr-2 h-4 w-4" />刷新会话</Button></div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{loading && !sessions.length && <p role="status" className="text-sm text-muted-foreground">正在读取本人会话…</p>}
{!loading && !error && !sessions.length && <p className="rounded-xl bg-muted/30 p-6 text-sm leading-6 text-muted-foreground">还没有本人微信会话。请先在发布与访问中连接个人微信,并完成本人配对。</p>}
{!loading && !error && !sessions.length && <p className="rounded-xl bg-muted/30 p-6 text-sm leading-6 text-muted-foreground">还没有本人微信会话。请先在发布与访问中连接并启用个人微信,然后用绑定时扫码的微信发送一条消息。</p>}
{!!sessions.length && <label className="block space-y-2 text-sm"><span>选择本人微信会话</span>
<select className="h-10 w-full rounded-md border border-input bg-background px-3" value={selected} disabled={busy} onChange={event => setSelected(event.target.value)}>
{!sessions.some(session => session.session_id === selected) && <option value={selected}>当前会话</option>}
+14 -13
View File
@@ -328,7 +328,7 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
setQrBindingKey(currentBindingKeyRef.current);
setQr(payload);
setQrOpen(true);
} else if (isPairing(payload) && matchesCurrent) {
} else if (isPairing(payload) && payload.kind === 'invite' && matchesCurrent) {
setCreatedPairing(payload);
}
await refresh();
@@ -458,9 +458,10 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
await loadCallers();
};
const issuePairing = async (kind: 'self' | 'invite') => {
const issueInvite = async () => {
const kind = 'invite';
if (!binding) return;
if (kind === 'invite' && binding.access_mode !== 'invited') {
if (binding.access_mode !== 'invited') {
setNotice('请先保存“本人及受邀联系人”,再生成邀请码');
return;
}
@@ -471,7 +472,7 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
operation_id: operationId(),
};
const result = await runMutation(key, 'createChannelPairing', input,
kind === 'self' ? '本人配对码已生成,请在目标微信对话中发送' : '邀请码已生成,请手动发给受邀联系人');
'邀请码已生成,请手动发给受邀联系人');
if (requestedAccountId === currentIdRef.current && isPairing(result)) setCreatedPairing(result);
};
@@ -633,7 +634,7 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
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' && <AccessStep binding={binding} accessMode={accessMode} callers={callers} pairing={activePairing} busy={busy} busyAction={busyAction} onMode={setAccessMode} onSave={() => void updateAccessMode()} onPair={kind => void issuePairing(kind)} onClearPairing={() => setCreatedPairing(null)} onRevoke={caller => void revoke(caller)} />}
{tab === 'access' && <AccessStep binding={binding} accessMode={accessMode} callers={callers} pairing={activePairing} busy={busy} busyAction={busyAction} onMode={setAccessMode} onSave={() => void updateAccessMode()} onInvite={() => void issueInvite()} onClearPairing={() => setCreatedPairing(null)} onRevoke={caller => void revoke(caller)} />}
{tab === 'activity' && <ActivityStep slug={slug} channelAccountId={currentId} refreshKey={activityRevision} activity={activity} nextOffset={activityOffset} busy={busy} onRefresh={() => void loadActivity()} onMore={() => void (activityOffset == null ? undefined : loadActivity(activityOffset))} onRetry={retryPart} />}
{tab === 'sessions' && <CloudChannelConversations slug={slug} activeView={activeView && tab === 'sessions'} />}
</section>;
@@ -685,20 +686,20 @@ function StepHeading({ number, title, description }: { number: string; title: st
return <div className="flex gap-3 border-t border-border/60 pt-5"><span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand/10 text-sm font-medium text-brand">{number}</span><div><h3 className="font-medium">{title}</h3><p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p></div></div>;
}
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 }) {
function AccessStep({ binding, accessMode, callers, pairing, busy, busyAction, onMode, onSave, onInvite, onClearPairing, onRevoke }: { binding: CloudChannelView | null; accessMode: 'self_only' | 'invited'; callers: CloudChannelCaller[]; pairing: CloudChannelPairing | null; busy: boolean; busyAction: string; onMode: (value: 'self_only' | 'invited') => void; onSave: () => void; onInvite: () => void; onClearPairing: () => void; onRevoke: (caller: CloudChannelCaller) => void }) {
const inviteEnabled = accessMode === 'invited' && binding?.access_mode === 'invited';
return <div className="space-y-5"><StepHeading number="2" title="谁能使用" description="默认只允许你本人。配对码和邀请都是短期、单次使用;创建者只能查看受邀者状态和费用,不会读取其私聊内容。" />
return <div className="space-y-5"><StepHeading number="2" title="谁能使用" description="默认只允许绑定时扫码的微信使用。受邀联系人需要单独邀请;你只能查看受邀者状态和费用,不能读取其私聊内容。" />
{!binding ? <p className="rounded-xl bg-muted/30 p-4 text-sm text-muted-foreground">请先完成微信连接。</p> : <>
<div className="space-y-3 rounded-xl border border-border p-4"><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'self_only'} onChange={() => onMode('self_only')} /><span><strong>仅本人</strong><span className="mt-1 block text-muted-foreground">只有当前账号配对的微信会话可以调用智能体。</span></span></label><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'invited'} onChange={() => onMode('invited')} /><span><strong>本人及受邀联系人</strong><span className="mt-1 block text-muted-foreground">受邀者拥有独立会话和费用记录,不能查看创建者或其他联系人内容。</span></span></label><Button variant="outline" disabled={busy || accessMode === (binding.access_mode === 'invited' ? 'invited' : 'self_only')} onClick={onSave}>{busyAction.startsWith('policy:') ? '保存中…' : '保存使用范围'}</Button></div>
<div className="grid gap-4 md:grid-cols-2"><div className="space-y-3 rounded-xl bg-muted/30 p-4"><h3 className="text-sm font-medium">本人配对</h3><p className="text-xs leading-5 text-muted-foreground">生成一次性配对码后,在你的目标微信私聊中发送完整配对命令。未完成配对前不会调用模型。</p><Button variant="outline" disabled={busy} onClick={() => onPair('self')}>{busyAction.includes(':self') ? '生成中…' : '生成本人配对码'}</Button></div>{accessMode === 'invited' && <div className="space-y-3 rounded-xl bg-muted/30 p-4"><h3 className="text-sm font-medium">邀请联系人</h3><p className="text-xs leading-5 text-muted-foreground">邀请码需要你手动发给联系人;平台不自动向微信联系人发消息。</p><Button variant="outline" disabled={busy || !inviteEnabled} onClick={() => onPair('invite')}>{busyAction.includes(':invite') ? '生成中…' : '生成邀请码'}</Button>{!inviteEnabled && <p className="text-xs text-amber-800">请先保存“本人及受邀联系人”后生成邀请码。</p>}</div>}</div>
{pairing && <PairingResult value={pairing} onClear={onClearPairing} />}
<div className="space-y-3 rounded-xl border border-border p-4"><div className="flex items-center justify-between gap-3"><h3 className="flex items-center gap-2 text-sm font-medium"><Users className="h-4 w-4" />已授权调用者</h3><span className="text-xs text-muted-foreground">{callers.length} 个</span></div>{!callers.length && <p className="text-sm text-muted-foreground">还没有已配对的调用者。</p>}{callers.map(caller => <div key={caller.caller_id} className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-sm"><div><p>{caller.access_mode === 'self_only' ? '本人微信会话' : '受邀联系人'}</p><p className="mt-1 text-xs text-muted-foreground">{statusLabel(caller.grant_state)} · 配对于 {shortDate(caller.created_at)}</p></div><Button variant="ghost" size="sm" className="text-destructive" disabled={busy || caller.grant_state === 'revoked'} onClick={() => onRevoke(caller)}>撤销</Button></div>)}</div>
<div className="space-y-3 rounded-xl border border-border p-4"><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'self_only'} onChange={() => onMode('self_only')} /><span><strong>仅本人</strong><span className="mt-1 block text-muted-foreground">使用绑定时扫码的微信,直接发送消息即可。</span></span></label><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'invited'} onChange={() => onMode('invited')} /><span><strong>本人及受邀联系人</strong><span className="mt-1 block text-muted-foreground">受邀者拥有独立会话和费用记录,不能查看创建者或其他联系人内容。</span></span></label><Button variant="outline" disabled={busy || accessMode === (binding.access_mode === 'invited' ? 'invited' : 'self_only')} onClick={onSave}>{busyAction.startsWith('policy:') ? '保存中…' : '保存使用范围'}</Button></div>
<div>{accessMode === 'invited' && <div className="space-y-3 rounded-xl bg-muted/30 p-4"><h3 className="text-sm font-medium">邀请联系人</h3><p className="text-xs leading-5 text-muted-foreground">邀请码需要你手动发给联系人;平台不自动向微信联系人发消息。</p><Button variant="outline" disabled={busy || !inviteEnabled} onClick={onInvite}>{busyAction.includes(':invite') ? '生成中…' : '生成邀请码'}</Button>{!inviteEnabled && <p className="text-xs text-amber-800">请先保存“本人及受邀联系人”后生成邀请码。</p>}</div>}</div>
{pairing?.kind === 'invite' && <PairingResult value={pairing} onClear={onClearPairing} />}
<div className="space-y-3 rounded-xl border border-border p-4"><div className="flex items-center justify-between gap-3"><h3 className="flex items-center gap-2 text-sm font-medium"><Users className="h-4 w-4" />已授权调用者</h3><span className="text-xs text-muted-foreground">{callers.length} 个</span></div>{!callers.length && <p className="text-sm text-muted-foreground">首次收到你的微信消息后,会在这里显示本人会话。</p>}{callers.map(caller => <div key={caller.caller_id} className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-sm"><div><p>{caller.access_mode === 'self_only' ? '本人微信会话' : '受邀联系人'}</p><p className="mt-1 text-xs text-muted-foreground">{statusLabel(caller.grant_state)} · 授权于 {shortDate(caller.created_at)}</p></div><Button variant="ghost" size="sm" className="text-destructive" disabled={busy || caller.grant_state === 'revoked'} onClick={() => onRevoke(caller)}>撤销</Button></div>)}</div>
</>}</div>;
}
function PairingResult({ value, onClear }: { value: CloudChannelPairing; onClear: () => void }) {
const copy = () => { if (value.code) void navigator.clipboard.writeText(`${value.kind === 'self' ? '配对' : '邀请'} ${value.code}`); };
return <div role="status" className="space-y-3 rounded-xl bg-amber-50 p-4 text-sm"><div className="flex items-start justify-between gap-3"><div><p className="font-medium">{value.kind === 'self' ? '本人一次性配对码' : '一次性邀请码'}</p><p className="mt-1 text-xs text-amber-900/70">{value.expires_at ? shortDate(value.expires_at) : '短期有效'};关闭此面板后不会再次展示。</p></div><Button variant="ghost" size="sm" onClick={onClear}>隐藏</Button></div>{value.code ? <div className="flex items-center gap-2"><code className="min-w-0 flex-1 rounded-lg bg-white px-3 py-2 font-mono text-base tracking-widest">{value.code}</code><Button aria-label="复制配对命令" variant="outline" size="sm" onClick={copy}><Copy className="h-4 w-4" /></Button></div> : <p className="text-xs text-amber-900/70">服务已受理,请在活动记录中查看操作状态。</p>}</div>;
const copy = () => { if (value.code) void navigator.clipboard.writeText(`邀请 ${value.code}`); };
return <div role="status" className="space-y-3 rounded-xl bg-amber-50 p-4 text-sm"><div className="flex items-start justify-between gap-3"><div><p className="font-medium">一次性邀请码</p><p className="mt-1 text-xs text-amber-900/70">{value.expires_at ? shortDate(value.expires_at) : '短期有效'};关闭此面板后不会再次展示。</p></div><Button variant="ghost" size="sm" onClick={onClear}>隐藏</Button></div>{value.code ? <div className="flex items-center gap-2"><code className="min-w-0 flex-1 rounded-lg bg-white px-3 py-2 font-mono text-base tracking-widest">{value.code}</code><Button aria-label="复制邀请命令" variant="outline" size="sm" onClick={copy}><Copy className="h-4 w-4" /></Button></div> : <p className="text-xs text-amber-900/70">服务已受理,请在活动记录中查看操作状态。</p>}</div>;
}
function ActivityStep({ slug, channelAccountId, refreshKey, activity, nextOffset, busy, onRefresh, onMore, onRetry }: {
+3 -3
View File
@@ -56,7 +56,7 @@ export function CloudSchedules({ slug, onOpen, proposal, onProposalClosed }: { s
{job.runs?.map((run, i) => <div key={i} className="flex items-center justify-between gap-3 border-t border-border/50 pt-3 text-sm">
<div><p>{cloudStatus(run.status)}{run.error_message && <span className="ml-2 text-muted-foreground">{run.error_message}</span>}</p>
{run.result_notification && <p className="mt-1 text-xs text-muted-foreground">{run.result_notification.state === 'waiting_context'
? '微信通知待恢复:请确认连接,并在已配对的微信对话中发送一条消息。任务结果已独立保存。'
? '微信通知待恢复:请确认连接,并在本人微信对话中发送一条消息。任务结果已独立保存。'
: '微信通知已配置,实际送达情况可在发布渠道的活动记录中查看。'}</p>}</div>
{run.conversation_available && <Button variant="ghost" onClick={() => onOpen({ thread_id: run.thread_id, client_thread_id: null, agent_slug: slug,
title: job.name, status: run.status, mode: 'published', unread: false, updated_at: '', run_id: null })}>查看结果</Button>}
@@ -160,8 +160,8 @@ function ScheduleNotification({ slug, value, onChange }: {
{channels.find(channel => channel.id === caller.channel_account_id)?.display_name ?? '个人微信'} · 本人对话 {index + 1}
</option>)}
</select></label>
<p className="text-xs leading-6 text-muted-foreground">只可选择你已配对的本人微信对话。任务仍在云端运行,结果保留在历史中,通知不会再次执行任务。</p>
{!loading && !error && !options.length && <p className="text-xs leading-6 text-muted-foreground">还没有可用的本人微信对话。请先在“发布与访问”的个人微信渠道中完成连接、本人配对并启用,然后回来刷新。</p>}
<p className="text-xs leading-6 text-muted-foreground">只可选择已经开始对话的本人微信。任务仍在云端运行,结果保留在历史中,通知不会再次执行任务。</p>
{!loading && !error && !options.length && <p className="text-xs leading-6 text-muted-foreground">还没有可用的本人微信对话。请先在“发布与访问”的个人微信渠道中完成连接并启用,再用绑定时扫码的微信发送一条消息,然后回来刷新。</p>}
{selected && !available && !loading && <p className="text-xs leading-6 text-muted-foreground">请恢复原连接,或重新选择通知目标。选择“仅保存在客户端”可关闭微信通知。</p>}
{error && <p role="alert" className="text-xs text-destructive">{error}。原通知配置已保留,可刷新后重试。</p>}
</section>;
+4 -3
View File
@@ -85,9 +85,10 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop
await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: unknown }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'wechatBindVerification'))).toBe(true);
if (await page.getByRole('button', { name: '关闭二维码', exact: true }).count()) await page.getByRole('button', { name: '关闭二维码', exact: true }).click();
await page.getByRole('button', { name: '使用权限', exact: true }).click();
await page.getByRole('button', { name: '生成本人配对码', exact: true }).click();
await expect(page.getByText('TEST1234', { exact: true })).toBeVisible();
await page.getByRole('button', { name: '隐藏', exact: true }).click();
await expect(page.getByText('使用绑定时扫码的微信,直接发送消息即可。', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '生成本人配对码', exact: true })).toHaveCount(0);
expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'createChannelPairing'))).toBe(false);
await page.screenshot({ path: testInfo.outputPath('wechat-access-without-pairing.png') });
await page.getByRole('button', { name: '连接微信', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('wechat-publication.png') });
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); });
+29 -12
View File
@@ -67,14 +67,31 @@ it('requires an explicit enable confirmation and sends the typed binding operati
expect(typeof input.operation_id).toBe('string');
});
it('uses the pairing code command and does not mutate when the access tab is opened', async () => {
it('uses the bound WeChat identity without offering or requesting a self pairing code', async () => {
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
await screen.findByRole('button', { name: '使用权限' });
fireEvent.click(screen.getByRole('button', { name: '使用权限' }));
fireEvent.click(await screen.findByRole('button', { name: '生成本人配对码' }));
await screen.findByText('ABC123');
expect(api.call.mock.calls.some(call => call[0] === 'channelPolicy')).toBe(false);
expect(api.call.mock.calls.find(call => call[0] === 'createChannelPairing')?.[1]).toEqual(expect.objectContaining({ kind: 'self', channel_account_id: 'wechat-1' }));
fireEvent.click(await screen.findByRole('button', { name: '使用权限' }));
await screen.findByText('使用绑定时扫码的微信,直接发送消息即可。');
expect(screen.queryByRole('button', { name: '生成本人配对码' })).toBeNull();
expect(screen.queryByRole('button', { name: '生成邀请码' })).toBeNull();
expect(api.call.mock.calls.some(call => ['createChannelPairing', 'channelPolicy'].includes(call[0]))).toBe(false);
});
it('still requires an explicit invitation for other contacts and copies the complete command', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation: string, input) => {
if (operation === 'channelBindings') return { items: [{ ...binding, access_mode: 'invited' }], available_accounts: [] };
if (operation === 'createChannelPairing') return { invitation_id: 'invite-a', binding_id: 'binding-1', provider_generation: 'provider-1', kind: 'invite', consumed: false, code: 'INVITE123' };
return base(operation, input);
});
const copy = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: copy } });
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
fireEvent.click(await screen.findByRole('button', { name: '使用权限' }));
fireEvent.click(await screen.findByRole('button', { name: '生成邀请码' }));
await screen.findByText('INVITE123');
fireEvent.click(screen.getByRole('button', { name: '复制邀请命令' }));
expect(copy).toHaveBeenCalledWith('邀请 INVITE123');
expect(api.call.mock.calls.find(call => call[0] === 'createChannelPairing')?.[1]).toEqual(expect.objectContaining({ kind: 'invite', channel_account_id: 'wechat-1' }));
});
it('looks up delivery by the result message ID before offering file retry', async () => {
@@ -151,7 +168,7 @@ it('drops an old QR session when switching bindings and ignores its late status'
const oldStatus = new Promise(resolve => { releaseOldStatus = resolve; });
let statusCalls = 0;
api.call.mockImplementation(async (operation: string, input: Record<string, unknown>) => {
if (operation === 'channelBindings') return { items: [binding, bindingB], available_accounts: [] };
if (operation === 'channelBindings') return { items: [{ ...binding, access_mode: 'invited' }, bindingB], available_accounts: [] };
if (operation === 'channelCallers') return { items: [] };
if (operation === 'channelActivity') return { items: [], next_offset: null };
if (operation === 'wechatBindStart') return { session_key: 'session-a', status: 'waiting', qrcode_url: 'https://provider.example/qr-a' };
@@ -181,16 +198,16 @@ it('clears the previous binding pairing, callers, and activity when switching ac
delivery: { state: 'known' }, timing: { created_at: '2026-09-13T00:00:00Z' },
};
api.call.mockImplementation(async (operation: string, input: Record<string, unknown>) => {
if (operation === 'channelBindings') return { items: [binding, bindingB], available_accounts: [] };
if (operation === 'channelBindings') return { items: [{ ...binding, access_mode: 'invited' }, bindingB], available_accounts: [] };
if (operation === 'channelCallers') return { items: input.channel_account_id === 'wechat-1' ? [callerA] : [] };
if (operation === 'channelActivity') return { items: input.channel_account_id === 'wechat-1' ? [activityA] : [], next_offset: null };
if (operation === 'createChannelPairing') return { binding_id: 'binding-1', provider_generation: 'provider-1', kind: 'self', consumed: false, code: 'PAIR-A' };
if (operation === 'createChannelPairing') return { binding_id: 'binding-1', provider_generation: 'provider-1', kind: 'invite', consumed: false, code: 'PAIR-A' };
throw new Error(`Unexpected operation ${operation}`);
});
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
fireEvent.click(await screen.findByRole('button', { name: '使用权限' }));
await screen.findByText('受邀联系人', { exact: true });
fireEvent.click(screen.getByRole('button', { name: '生成本人配对码' }));
fireEvent.click(screen.getByRole('button', { name: '生成邀请码' }));
await screen.findByText('PAIR-A');
fireEvent.click(screen.getByRole('button', { name: '活动记录' }));
await screen.findByText('词元 7');
@@ -198,7 +215,7 @@ it('clears the previous binding pairing, callers, and activity when switching ac
fireEvent.change(await screen.findByLabelText('当前渠道'), { target: { value: 'wechat-2' } });
await waitFor(() => expect(screen.queryByText('PAIR-A')).toBeNull());
fireEvent.click(screen.getByRole('button', { name: '使用权限' }));
await screen.findByText('还没有已配对的调用者。');
await screen.findByText('首次收到你的微信消息后,会在这里显示本人会话。');
expect(screen.queryByText('受邀联系人', { exact: true })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '活动记录' }));
await screen.findByText('还没有渠道活动。');