OpenHuman 身份市场(Identity Marketplace)卖方能力 Web-only 设计解析:桌面端 Trading 页引导到 tiny.place 的完整实现
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
导读
本文基于 OpenHuman 仓库中的设计规格文档 docs/superpowers/specs/2026-07-24-identity-marketplace-seller-web-only-design.md 及其配套实施计划 docs/superpowers/plans/2026-07-24-identity-marketplace-seller-web-only.md,完整拆解"身份市场卖方能力仅限 Web 端(seller-side is web-only by design)"这一架构决策:从问题背景、决策依据、UI 引导实现、openUrl跨端打开链路、测试策略到回滚方案,并辅以仓库内真实源码(如 app/src/utils/openUrl.ts)佐证底层原理。读完本文,你将掌握"如何通过最小化的纯前端改动消除产品死胡同、并在仓库内固化架构边界"这一类问题的完整解题范式。
问题背景:桌面端身份市场只有"买方"通路
在 OpenHuman 桌面应用中,身份市场(Identity Marketplace)位于Agent World → Identities → Trading标签页。该标签页在设计之初只实现了**买方侧(buyer-side)**动作:
- 浏览在售列表(browse listings)
- 购买(buy)
- 出价(bid)
- 发起报价(make an offer)
而**卖方侧(seller-side)**动作——将自己的@handle上架出售(list for sale)、接受/拒绝收到的报价或出价(accept / reject offer)——在桌面端完全没有实现路径。用户一旦想出售身份,打开 Trading 标签页便会走到一条死胡同(dead-end)。这正是 issue #4920 描述的核心矛盾:"Identity marketplace is buyer-only: no list-for-sale or accept/reject-offer path",属于 epic #4776 §9(Trading / Identities marketplace)的子项。
这一问题的根源不在 UI 层,而在服务端与 SDK 层:
- tiny.place 后端不暴露卖方路由(create-listing、accept/reject-offer),即服务端根本没有对应的 API;
- 仓库内 vendor 的 Rust SDK(设计文档中指
vendor/tinyplace/sdk/rust/src/api/marketplace.rs)被明确标注为"compatibility wrapper for marketplace endpoints used by OpenHuman"——一个仅覆盖买方场景的兼容性封装。
因此,上架身份与响应报价这类操作的真实落点,是tiny.place 的 Web 应用,而非桌面客户端。
核心决策:Seller-side actions are web-only by design
面对 #4920,存在两条截然不同的解决路线:
- 路线 A(重型):补齐完整的卖方技术栈——vendor SDK 卖方方法 → Rust core 卖方 handler(
handle_tinyplace_marketplace_*)→invokeApiClient.ts卖方方法 → UI。这是一条"SDK → core handler → invokeApiClient → UI"的全链路实现,成本高且受制于后端能力。 - 路线 B(务实):接受现状,把"卖方能力是 Web-only"作为**明确的设计决策(by design)**固化下来,并消除桌面端的死胡同。
设计文档选择了路线 B,其落地方式由三件事构成:
- 移除应用内死胡同:在 Trading 标签页加入一条清晰的提示与跳转入口,把用户引导到 Web 流程;
- 在仓库内记录该范围决策:通过两处文档注释(前端组件头部 docstring + Rust 领域模块 doc)让"Web-only"成为可检索、可追溯的架构事实;
- 把 #4776 §9 的对应条目标记为 N/A:在 epic 上明确"不做"而非"未做"。
为什么是"决策"而不是"临时绕过"
值得强调的是,文档用by design而非 workaround 来定性这一方案。理由有三:
- 后端没有卖方路由,这是服务端能力边界的事实;
- vendored SDK 的定位就是买方兼容层,扩展它属于 SDK 厂商(tiny.place)的责任边界;
- 卖方流程(上架、议价、成交)天然适合在 Web 应用内完成,桌面端重复实现既无必要也无收益。
将结论写入代码注释,等于把"当时为什么这样定"的知识沉淀进仓库,避免后来者再次踩进"补全卖方栈"的陷阱。
实施方案拆解(Scope)
1. UI 引导:Trading 标签页的SellOnWebNote
核心改动位于app/src/agentworld/pages/IdentitiesSection.tsx的 Trading 标签页:在 listings / recent-sales 区域下方追加一条常驻提示信息(persistent info note):
ℹ️ Selling a handle or responding to offers happens on tiny.place.[Open tiny.place →]
配套实施计划给出了完整的实现细节,包括组件常量与 JSX 块:
// Seller-side identity actions (list a handle for sale, accept/reject an offer) // are web-only — the tiny.place backend exposes no seller routes and the // vendored SDK is a buyer-side compatibility wrapper (see #4920). We point // sellers at the web app instead. Hardcoded prod URL, matching the // `FUND_PAGE_URL` precedent in X402ConfirmDialog.tsx. const SELL_ON_WEB_URL = 'https://tiny.place/identities';{/* Seller-side is web-only (#4920): no in-app list-for-sale / accept-offer path exists, so point sellers at the tiny.place web app. */} <div className="rounded-lg border border-line bg-surface-muted/40 p-3" >* * Seller-side actions (list a handle for sale, accept / reject an offer/bid) * are intentionally NOT in-app — they are web-only on tiny.place. The backend * exposes no seller routes and the vendored SDK is a buyer-side compatibility * wrapper, so the Trading tab links sellers to the web app instead (#4920).- 后端:在 tinyplace 核心领域模块(设计文档指向
src/openhuman/tinyplace/mod.rs)的模块 doc 中追加一节:
//! //! ## Marketplace scope (buyer-side only) //! //! The identity marketplace handlers here are **buyer-side only**: buy a //! listing, bid, and make an offer. Seller-side actions — listing a handle for //! sale and accepting / rejecting offers — are **web-only on tiny.place** (the //! backend exposes no seller routes). The desktop Trading tab links sellers to //! the web app instead. See #4920.后者是纯文档注释,不产生任何代码行为变化,可通过cargo check与cargo fmt验证(doc comment 不会被 reflow)。
4. 测试与覆盖率门禁
新增改动受仓库"变更行覆盖率 ≥80%"门禁约束,因此必须配套测试。设计在IdentitiesSection.test.tsx中新增两个 Vitest 用例:
describe('Trading tab — seller web-only note', () => { test('renders the seller pointer note on the Trading tab', async () => { render(<IdentitiesSection />); await gotoTab('Trading'); const note = await screen.findByTestId('sell-on-web'); expect(note).toHaveTextContent(/selling a handle or responding to offers/i); }); test('CTA opens the tiny.place identities page via openUrl', async () => { render(<IdentitiesSection />); await gotoTab('Trading'); await userEvent.click(await screen.findByTestId('sell-on-web-cta')); expect(vi.mocked(openUrl)).toHaveBeenCalledWith('https://tiny.place/identities'); }); });配套的openUrlmock 需要在测试文件顶部注册,确保断言的是"CTA 调用了openUrl且参数正确",而不是真正触发 Tauri 系统调用:
// External-link opener — assert the seller CTA hands off to the OS browser // without actually invoking Tauri. vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));实施计划还给出了标准的 TDD 流程(先写失败测试 → 实现 → 验证通过)与验证命令:
# Step 3:验证新测试先失败 pnpm --filter openhuman-app test -- IdentitiesSection --run -t "seller web-only" # Step 7:验证新测试通过且不破坏既有用例 pnpm --filter openhuman-app test -- IdentitiesSection --run5. GitHub 簿记(外联动作)
第 3 个任务是 GitHub 侧的书签式记录,不属于代码改动:在 #4920 上评论 Web-only 的解决方案,并将 #4776 §9 中的"Sell / list a handle for sale""Accept / reject offer"两行状态改为N/A (web-only — #4920),把- [ ] Sell / list flow works勾选为- [x] Sell / list flow works — N/A, web-only (#4920)。这些动作通过ghCLI 完成,并在 PR 正文中注明,属于对外可见的操作,需要用户显式确认后再执行。
明确不做的事(YAGNI 边界)
设计文档用专门的 "Out of scope (YAGNI)" 小节划清了边界,这本身就是一次很好的"范围管理示范":
- vendor SDK 的卖方方法(
create_listing/accept_offer/reject_offer); - Rust core 的
handle_tinyplace_marketplace_*卖方 handler; invokeApiClient.ts的卖方方法;IdentitiesSection.tsx的整文件 i18n 迁移(新字符串保持硬编码英文)。
组件与数据流:一次"零网络"的浏览器移交
改动后的数据流可以用下面这张图完整表达:
Trading tab (IdentitiesSection.tsx) └─ SellOnWebNote ──click──▶ openUrl('https://tiny.place/identities') └─ tauri-plugin-opener → OS default browser值得注意的是:本次改动不引入任何 core RPC,也不产生应用自身的网络请求——CTA 只是把控制权移交给操作系统默认浏览器。这从架构上保证了改动的低风险:应用进程内没有任何新的攻击面或错误路径。
错误处理:openUrl的 CEF IPC 桥接缺口与回退
设计文档强调"openUrl()已经处理了 CEF IPC-bridge 缺口,因此本改动不引入新的错误面"。要理解这句话,需要读一下 app/src/utils/openUrl.ts 的真实实现:
export const openUrl = async (url: string): Promise<void> => { const normalizedUrl = url.trim(); if (isTauri()) { try { await tauriOpenUrl(normalizedUrl); return; } catch (err) { Sentry.addBreadcrumb({ category: 'ipc', level: 'warning', message: 'tauriOpenUrl failed; evaluating fallback', data: { url: getTelemetryUrl(normalizedUrl), error: String(err) }, }); if (!isHttpUrl(normalizedUrl)) { throw err; } // http(s) URL — safe to fall back to window.open. } } window.open(normalizedUrl, '_blank', 'noopener,noreferrer'); };这里的三个关键机制与本次改动的可靠性直接相关:
CEF 桥接缺口:CEF embedder 中,IPC bridge(
window.ipc.postMessage)是在on_after_created之后才注入到 renderer 侧的。如果点击恰好落在这个注入间隙,tauri-plugin-opener的invoke()胶水会以TypeError: Cannot read properties of undefined (reading 'postMessage')拒绝。openUrl对此的应对是:http(s) URL 回退到window.open,保证用户流程不中断。非 http(s) 场景直接抛错:对
obsidian://这类自定义 scheme,回退到window.open反而会错误地打开一个无法处理自定义 scheme 的 Tauri webview 窗口,因此这种情况下选择向上传播错误,由调用方决定如何展示——这是一种比"静默失败"更好的用户体验。低 PII 遥测:
getTelemetryUrl对 http(s) URL 只保留 origin(host),对mailto:/obsidian://只保留 protocol——路径、查询串、fragment 中可能携带的 token、邮箱、本地路径绝不离开设备。这保证了即便是失败场景,埋点数据也不会泄露敏感信息。
由于本改动复用的正是这条经过生产验证的链路,错误处理成本为零。
测试策略与 E2E 豁免
设计文档给出了清晰的测试分层:
- 单元测试(Vitest):渲染
TradingTab,断言卖家提示文案存在、CTA 携带identities.sellOnWeb分析 ID、点击后以期望 URL 调用 mockedopenUrl; - 无 Rust 行为变更 → 无需 Rust 测试;
- E2E 明确跳过(approved exception):该引导是一个静态提示条,其 CTA 通过
openUrl移交操作系统浏览器,不存在跨进程行为可供桌面 WDIO E2E 断言——单测已经覆盖了全部可验证行为。这被记录为对仓库标准"单元测试 + E2E"期望的批准例外,并在设计文档 Testing 节与测试describe块注释中双重备案。
这一"为什么不需要 E2E"的论证过程,本身就是值得复用的测试决策方法论:E2E 的价值在于跨进程/跨组件集成行为的回归保护,当改动被刻意设计为"无跨进程行为"时,单测就是充分且必要的覆盖手段。
回滚与风险评估
设计文档将风险评级为低:
- 改动是纯增量的 UI 提示 + 文档注释;
- 不改变既有的 buy/bid/offer 流程的任何行为;
- 不触碰任何 i18n/locale 文件(字符串为硬编码英文,与文件现状一致);
- 回滚即干净地移除:提示块 + 头部 docstring 注释 + 两个单测用例。
配套实施计划还给出了完整的质量门禁命令(类型检查、lint、格式化):
pnpm typecheck && pnpm --filter openhuman-app lint -- --fix app/src/agentworld/pages/IdentitiesSection.tsx app/src/agentworld/pages/IdentitiesSection.test.tsx && pnpm --filter openhuman-app exec prettier --write app/src/agentworld/pages/IdentitiesSection.tsx app/src/agentworld/pages/IdentitiesSection.test.tsx以及 Rust 侧文档注释的验证:
GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml cargo fmt --manifest-path Cargo.toml设计方法论总结:什么时候用"Web-only"收窄客户端范围
这则设计文档的价值不止于一个 UI 提示,它示范了一套可复用的架构决策框架:
- 先确认能力边界再决定实现路线:卖方功能的缺失根源在后端路由与 SDK 定位,而非 UI 疏漏——先诊断"哪一层没有能力",避免在错误层级堆代码;
- 用"by design"固化现状:把"为什么不做"写进代码注释与领域模块文档,让后来者免于重复调研(这一点也可以对照同仓库的姊妹设计 docs/superpowers/specs/2026-07-30-tinyplace-feed-post-media-web-only-design.md,它采用了同样的 Web-only 收窄模式);
- 以最小改动消除产品死胡同:一次纯增量的信息提示 + 浏览器移交,零网络、零新错误面,换来用户路径的完整闭环;
- 显式声明 YAGNI 边界:明确列出"不做"清单,防止范围蔓延;
- 测试与例外都留下书面理由:单测覆盖行为、E2E 豁免给出论证,让质量门禁既严格又可解释。
对于任何"桌面端功能与服务端/Web 端能力出现职责分歧"的场景——身份市场、内容发布、社交互动等——这套"先界定能力边界,再以最小改动把用户引导到正确落点,最后在仓库内固化决策"的流程都值得直接借鉴。
参考链接
- 设计规格:docs/superpowers/specs/2026-07-24-identity-marketplace-seller-web-only-design.md
- 实施计划(含完整代码示例与命令):docs/superpowers/plans/2026-07-24-identity-marketplace-seller-web-only.md
- 跨端 URL 打开实现:app/src/utils/openUrl.ts
- 同模式的姊妹设计:docs/superpowers/specs/2026-07-30-tinyplace-feed-post-media-web-only-design.md
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考