{"id":"6590fd6f-cf7d-49f9-92c0-a98aaef42292","entityType":"agent","slug":"clawhub-mtty-ai-opphub","name":"opphub","canonicalUrl":"https://www.xpersona.co/agent/clawhub-mtty-ai-opphub","canonicalPath":"/agent/clawhub-mtty-ai-opphub","generatedAt":"2026-10-10T08:44:56.657Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"editorial-content","verified":true,"confidence":"high","updatedAt":"2026-10-10T04:28:01.898Z","emptyReason":null},"description":"偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。 Skill: opphub Owner: mtty-ai Summary: 偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。 Tags: latest:5.0.0 Version history: v5.0.0 | 2026-07-27T08:56:48.339Z | user OPC → User 全量重命名: /api/opc/ → /api/user/, token 字段 opc_id → user_id. check-update.js 加 stripPrefix 函数 (v4.0.1 漏定义, 修 plugin cron 'skill 没查到' 错误) Archive index: Archive v5.0.0: 49 files, 223936 bytes Files: _meta.json (125b), bin/opphub (7499b), bin/opphub-check-","descriptionLabel":"Technical summary","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1.7K downloads reported by the source. Last updated 10/10/2026.","installCommand":"clawhub skill install s17ampw22fp45rj3rm81m8ssd989v9cx:opphub","sourceUrl":"https://clawhub.ai/mtty-ai/opphub","homepage":"https://clawhub.ai/mtty-ai/skills/opphub","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/mtty-ai/opphub","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/mtty-ai/skills/opphub","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":65,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。 Skill: opphub Owner: mtty-ai Summary: 偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。 Tags: latest:5.0.0 Version history: "},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-10T04:28:01.898Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T04:28:01.898Z","emptyReason":null},"stars":null,"forks":null,"downloads":1686,"packageName":null,"latestVersion":"5.0.0","tractionLabel":"1.7K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T04:28:01.898Z","emptyReason":null},"lastUpdatedAt":"2026-10-10T04:28:01.898Z","lastCrawledAt":"2026-10-10T04:28:01.898Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-11T04:28:01.898Z","lastVerifiedAt":null,"highlights":[{"version":"5.0.0","createdAt":"2026-07-27T08:56:48.339Z","changelog":"OPC → User 全量重命名: /api/opc/ → /api/user/, token 字段 opc_id → user_id. check-update.js 加 stripPrefix 函数 (v4.0.1 漏定义, 修 plugin cron 'skill 没查到' 错误)","fileCount":49,"zipByteSize":223936}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s17ampw22fp45rj3rm81m8ssd989v9cx:opphub","setupComplexity":"low","setupSteps":["Setup complexity is LOW. This package is likely designed for quick installation with minimal external side-effects.","Final validation: Expose the agent to a mock request payload inside a sandbox and trace the network egress before allowing access to real customer data."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-10T08:44:56.655Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mtty-ai-opphub/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"high","updatedAt":"2026-10-10T04:28:01.898Z","emptyReason":null},"readme":"Skill: opphub\n\nOwner: mtty-ai\n\nSummary: 偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。\n\nTags: latest:5.0.0\n\nVersion history:\n\nv5.0.0 | 2026-07-27T08:56:48.339Z | user\n\nOPC → User 全量重命名: /api/opc/ → /api/user/, token 字段 opc_id → user_id. check-update.js 加 stripPrefix 函数 (v4.0.1 漏定义, 修 plugin cron 'skill 没查到' 错误)\n\nArchive index:\n\nArchive v5.0.0: 49 files, 223936 bytes\n\nFiles: _meta.json (125b), bin/opphub (7499b), bin/opphub-check-update.js (6347b), bin/opphub-configure.js (7552b), bin/opphub-cron-setup.js (10591b), bin/opphub-find-peers.js (4613b), bin/opphub-knowledge-add.js (5486b), bin/opphub-knowledge-autofill.js (1140b), bin/opphub-knowledge-card.js (15405b), bin/opphub-knowledge-discover.js (15498b), bin/opphub-knowledge-ingest-batch.js (8814b), bin/opphub-knowledge-match.js (13515b), bin/opphub-knowledge-relate.js (20077b), bin/opphub-knowledge-search.js (4394b), bin/opphub-knowledge-status.js (4246b), bin/opphub-knowledge-submit.js (11522b), bin/opphub-oauth-login.js (25839b), bin/opphub-plugin-check.js (2355b), bin/opphub-token-refresh.js (4067b), CHANGELOG.md (6362b), docs/runtime-channel-renderer-v31-design.md (10948b), docs/server-schema-v31-design.md (3745b), docs/server-schema-v32-design.md (16162b), docs/superpowers/plans/2026-07-23-phase1-nav-rewrite.md (32098b), docs/superpowers/plans/2026-07-23-phase2-confirm-and-group.md (24529b), docs/superpowers/plans/2026-07-23-phase3-modal-interactions.md (23297b), docs/superpowers/plans/2026-07-23-phase4-bug-fixes.md (27089b), docs/superpowers/plans/2026-07-23-phase5-v1-conflicts-radar.md (7761b), docs/superpowers/plans/2026-07-24-batch1-push-pipeline.md (25710b), docs/superpowers/plans/2026-07-24-dashboard-optimize.md (27740b), docs/superpowers/specs/2026-07-23-opphub-product-design.md (19197b), docs/superpowers/specs/2026-07-24-opphub-dashboard-optimize-design.md (12998b), docs/superpowers/specs/2026-07-24-opphub-discover-to-order-flow-design.md (46430b), docs/v3.1-architecture.md (30966b), flow/registration.md (2512b), INTERNAL.md (9831b), lib/error-format.js (5402b), lib/intent-card.js (6550b), lib/opphub-plugin-client.js (9380b), lib/opphub-server-client.js (1770b), memory/2026-07-24.md (11335b), package.json (863b), README.md (181b), scripts/check-version.js (2737b), skill-card.md (2776b), SKILL.md (4244b), tests/e2e-verify.js (16596b), tests/mock-knowledge-server.js (4038b), tests/unit-knowledge-card.js (10639b)\n\nFile v5.0.0:SKILL.md\n\n---\nname: opphub\nversion: 5.0.0\ndescription: 偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。\nauthor: mtty-ai\nhomepage: https://github.com/mtty-ai/opphub-skill\nentry: bin/opphub\ndefaultLocale: zh-CN\nrequires:\n  bins:\n    - node>=18\n    - openclaw\n    - jq\n    - curl\n    - security\n    - openssl\n    - xdg-open\n  env: []\n  platform:\n    darwin: full\n    linux: full\n    windows: none\nmetadata:\n  api: https://api.opphub.ruiplus.cn\n  deviceFlow:\n    authorize: https://api.opphub.ruiplus.cn/api/oauth/device/code\n    token: https://api.opphub.ruiplus.cn/api/oauth/device/token\n    userinfo: https://api.opphub.ruiplus.cn/api/oauth/userinfo\n  refreshToken:\n    endpoint: https://api.opphub.ruiplus.cn/api/oauth/token\n  client_id: opphub-plugin\n  scope: profile ws:read ws:write\n---\n\n# 偶合 OppHub\n\n把偶合账号接入 OpenClaw，录入公司、查商机、收推送。\n\n支持 3 种触发方式：\n- IM 群聊 / 私聊 @bot（飞书 / 微信 / 钉钉 / Telegram 等）\n- OpenClaw 后台直接调用\n- cron 定时任务\n\n具体 IM 取决于 OpenClaw runtime 已装的 channel plugin。\n\n---\n\n## 一句话介绍\n\n录入公司、查商机、收推送，3 件事 bot 都替你跑。\n\n---\n\n## 安装\n\n```bash\nopenclaw skills install @mtty-ai/opphub\nopenclaw plugins install clawhub:@mtty-ai/opphub\n```\n\n两条都要装：\n\n- `opphub` skill：在 IM 里跟 bot 对话\n- `opphub` plugin：装好后 bot 收得到推送，登录态写 macOS Keychain\n\n装完自动重启 gateway。\n\n依赖 runtime 自动校验，不缺。\n\n---\n\n## 五步上手\n\n### 1. 注册偶合账号\n\n去 <https://api.opphub.ruiplus.cn/register>，邮箱/手机号 + 6 位验证码。\n\n### 2. 验证 plugin 装好\n\n```bash\nopphub plugin-check --json\n```\n\n返 `installed: true` 就 OK。没装好跑 `openclaw plugins install clawhub:@mtty-ai/opphub` 重装。\n\n### 3. @bot 说\"偶合登录\"\n\nbot 走 OAuth device flow：\n- bot 私聊发你激活链接（不会在群里贴验证码）\n- 你打开链接点同意\n- 登录态自动写 Keychain，下次不用再登\n\n### 4. @bot 说\"偶合配置\"\n\nbot 列出你本机已装的 IM 通道（飞书/微信/钉钉/Telegram 等），你选一个作**默认推送通道**。后续商机推送、cron 提醒都走这个通道。\n\n跳过这步也行——bot 会用 server 端上次选的通道；不放心就先跑一次确认。\n\n### 5. @bot 说\"偶合录入 [公司名]\"\n\nbot 自动 6 步跑完，只问你一次\"是否入库\"：\n\n- 联网查工商 / 业务 / 招聘 / 项目\n- 推断行业（MCN / SaaS / 律所 / 制造）\n- 拆能力卡片 + 上下游\n- 给你 1 张清单确认\n- 批量入库（幂等，重复跑不冲突）\n- 跑上下游匹配，列结果\n\n> 第 4 步拆出能力后, bot **不直接入库**, 先给一张确认清单 (含 4 种 entryType: 我能提供/我想找/我的依赖/同行关系).\n> 用户回复:\n> - 「确认」 → bot 调 `knowledge-submit --confirm` 入库\n> - 「删 <type.dimension>」 → bot 去掉某条重发清单\n> - 「重抽」 → 回到阶段 1 重新联网查\n> - 「改 <字段>」 → 改解析字段后重发清单\n\n---\n\n## 常用命令\n\n| 触发方式 | bot 做的 |\n|---|---|\n| 偶合登录 | OAuth 登录 |\n| 偶合状态 | 看登录态 + 推送通道 + 知识库 |\n| 偶合配置 | 选默认推送通道 |\n| 偶合录入 [公司] | 录入公司能力画像 |\n| 偶合录入关联公司 + 合同 xls | 解析合同拆上下游 |\n\n**触发方式**：群里 @bot、私聊 bot、OpenClaw 后台调用、cron 定时都可以。\n\n---\n\n## 常见问题\n\n**没收到推送？**\n确认 plugin 装好：`opphub plugin-check --json`。没装就跑 `openclaw plugins install clawhub:@mtty-ai/opphub`。\n\n**token 过期了？**\n@bot 说\"偶合登录\"重新走一遍。\n\n**浏览器没弹？**\n复制 bot 私聊里的链接手动打开。\n\n**录入撞同行业了？**\nbot 会弹卡片让你选行业。\n\n**录入冲突了？**\nbot 会列新旧版本让你选保留哪个。\n\n---\n\n## 卸载\n\n```bash\nopenclaw plugins uninstall clawhub:@mtty-ai/opphub\nopenclaw skills uninstall @mtty-ai/opphub\n```\n\nKeychain 里的登录态要手动清：macOS Keychain 搜 `opphub:default` 删除。\n\n---\n\n## 反馈\n\nissue: https://github.com/mtty-ai/opphub-skill/issues\n\nFile v5.0.0:README.md\n\n# 偶合 OppHub · OpenClaw Skill\n\nOpenClaw bot skill · device flow 登录 · 与 opphub-plugin 共 Keychain\n\n详见 SKILL.md (本仓 bot skill 模式说明, 由 OpenClaw 加载)\n\nFile v5.0.0:_meta.json\n\n{\n  \"ownerId\": \"kn7ethqpnt6p4vba8zp33jx2hh89vmkc\",\n  \"slug\": \"opphub\",\n  \"version\": \"5.0.0\",\n  \"publishedAt\": 1785142608339\n}\n\nFile v5.0.0:CHANGELOG.md\n\n# Changelog\n\n公开版仅保留用户可感知的功能变更。内部迭代历史不入仓。\n\n## v5.0.0 (2026-07-24) ⚠️ BREAKING\n\n### 加 (New)\n- **架构重写**：skill 不再写死任何业务词 / 行业模板 / 平台字典\n  - 删除 `INDUSTRY_TEMPLATES` (mcn/saas/law/mfg 4 个行业的所有 abilities/upstream/downstream/peer 列表)\n  - 删除 `INDUSTRY_SIGNALS` (token 频率猜行业)\n  - 删除 `PLATFORM_TERMS` / `GENERIC_TERMS` / `TAIL_GENERIC` / `MID_GENERIC` (所有 stop word 词表)\n  - skill 现在认任何中文公司 rawText：律所 / 餐饮 / 制造 / 数字营销 / … 都用同一套代码\n- **LLM 任务包**：每张卡 + JSON 输出都含\n  - `evidenceCandidates` (string[30]): n-gram 频次排名 + 长度加分\n  - `evidenceAnswerPrompt` (string): 单卡 LLM 选择规则 + JSON 输出格式\n  - `llmInstruction` (string): 顶层 LLM 一次性任务流 (read → pick → rewrite → submit)\n  - 占位符 `(证据词: <待 LLM 填写>)` 在 rawText 里等 LLM 填\n- **heading 驱动拆卡**:\n  - rawText `## 业务描述` 节下 `### 维度名` 直接生成 ability 卡片\n  - `### 上游依赖 / 下游客户 / 同业联盟` 子标题自动判定 type (无需模板)\n  - 其它行业 (律所/餐饮等) 写啥拆啥: `### 公司诉讼` / `### 火锅堂食` 都能识别\n\n### 改 (Changed)\n- 数据流从 \"skill 输出 → 用户确认 → LLM 再审 → submit\" 多步，简化为 \"skill 输出 LLM 任务包 → LLM 一次性完成 → submit\"\n- 无行业推断 (token 频率猜), 行业归属由 LLM 在对话里判断并传入\n\n### 弃用 (Deprecated)\n- 任何用户写死的维度词表 (e.g. 老 v4.x 模板里硬编码的\"达人营销\"/\"短视频内容制作\")\n\n### 测 (Test)\n- 11 个单元测试: 通用性守卫 (源码不许有业务词) / heading 拆 / n-gram 候选 / 占位符 / LLM 提示\n- 用 `npm test` 跑\n\n## v4.0.10 (2026-07-24)\n\n### 加 (New)\n- **knowledge-card evidence 提取加固** (extractEvidenceFromDesc):\n  - 长度上限 6→8, 让 \"短视频内容制作\"/\"店铺代运营\" 等 7-8 字短语能拆出\n  - 新增 PLATFORM_TERMS 优先扫 (抖音/快手/小红书/B站/视频号 等 14 平台)\n  - 解决 \"精选小红书博主合作\" 短语被 6 字限制吞掉的 case, 拆出关键词 \"小红书\"\n  - 尾部泛词剥离 (等等/行业/领域/场景/赛道/玩法/业务/服务/平台/资源)\n  - 列表前缀剥离 (包括/比如/例如/以及)\n  - 人称前缀剥除 (我们/你/他/她/它)\n\n### 测 (Test)\n- 单元测试 9 → 12 (新增 3 个边缘 case 覆盖人称/列表前缀/跨公司对比)\n- `npm test` 全部 12 测试通过 (~600ms)\n\n## v4.0.9 (2026-07-24)\n\n### 修 (Fixed)\n- **knowledge-card 证据词提取**: 之前用模板写死的 `purpose` 字符串当证据词源, 不同公司卡片雷达词趋同 (e.g. 都显示 \"KOL 投放\"/\"媒介代理\")。现在从 rawText 自然语言描述里挖真实关键词 (拆标点 + 滤停用前缀 + 限 2-6 字名词性片段), 两家公司 evidence 完全独立。\n- **证据词格式统一**: 修复 `buildCard()` 漏传 `evidenceList` 参数 bug。所有卡片现在统一输出 `(证据词: kw1, kw2, ...)` 格式, 去掉旧 `(证据: rawText 包含 \"${dim}\")` 兜底文案 (前端 regex 解析不到)。\n- **雷达不再有杂质**: 前端雷达原显示 \"蓝色光标\"/\"宝马等\"等公司名/团队背景, 现在都是真实能力词 (达人筛选/店铺搭建/活动策划等)。\n- **extractParsedFields 城市检测**: 不再被同业联盟里 `无忧传媒(北京...)` 这类字符串污染。限定到「地址字段 || 工商信息节」内查找城市。\n- **knowledge-discover 骨架校验**: 加 `SKELETON_PATTERNS` 检测, rawText 含 `(名称 / 法人 / 注册资本...)` 等填空内容时直接拒绝 (skeleton_unfilled), 强制 LLM 用 web_search 搜真实数据。\n- **knowledge-discover 搜索指令内嵌**: `--name` 模式输出 `_explicitInstructions` 字段, 列出每节搜索命令 + 禁止传空骨架警告。\n\n### 加 (New)\n- 9 个单元测试覆盖 `extractEvidenceFromDesc` / `extractDimDesc` / `extractParsedFields` 三大核心逻辑。用 `npm test` 跑 (`tests/unit-knowledge-card.js`)。\n\n### 测 (Test)\n- `node --test tests/unit-knowledge-card.js` 全部 9 测试通过 (~450ms)\n- `npm test` 端到端验证脚本 (`tests/e2e-verify.js`) 不动\n\n## v4.0.0 (2026-07-22)\n\n### 加 (New)\n\n- OAuth Device Flow 登录 + token 自动写 macOS Keychain / Linux AES-256-GCM 加密文件\n- 6 步闭环引导: 注册 → 登录 → 装 plugin → 配默认通道 → 知识库 → 商机匹配\n- 开放式知识库录入: discover (生成 rawText 骨架) + card (按行业拆能力卡片) + submit (idempotent 入库 + 冲突检测)\n- 关联公司录入: relate (xls/html 合同解析, 拆 top 客户 / 供应商 / 类别聚合)\n- bot turn 调用 LLM 工具 (web_search / web_fetch / image / pdf / memory_search / wiki_search) 作为数据源\n- 错误结构化: 统一返 `{ ok, stage, code, retryable, hint, traceId }`\n\n### 改 (Changed)\n\n- 通道元数据真源 = OpenClaw runtime (读 `openclaw channels list --json`)\n- 默认通道真源 = opphub-server (PATCH `/api/user/channels/default`)\n- token 写入方 = opphub-plugin (Keychain 单写入, skill 不双源)\n- OAuth device flow start 30 秒内复用 (避免重复申请 device_code)\n- ingest-batch 用 mkdtemp 每次独立目录 (避免并发竞态)\n\n### 弃用 (Deprecated)\n\n- `bin/opphub-knowledge-autofill.js` (DEPRECATED 2026-07-22) — 读本机敏感源违反产品红线\n- `bin/opphub-cron-setup.js` (DEPRECATED 2026-07-22) — v4 cron 改由 plugin 维护\n\n### 修 (Fixed)\n\n- knowledge-card 行业歧义时双 JSON 输出\n- token refresh 返字符串 (无法读 expires_at) 改为返完整对象\n- OAuth URL/path 改 spawn argv 避免 shell 注入\n- knowledge-relate 必需列校验 + 金额解析增强 (逗号/中文/括号负数/货币符号)\n- knowledge-relate 二进制 xls 检测 (拒绝 OLE2 二进制)\n\n### 安全 (Security)\n\n- skill 不再读 token / IM channel / openclaw.json / outbox / MEMORY (隐私红线)\n- skill 不再硬编码真实 user open_id (cron fallback 改为返 null)\n- 错误响应含 traceId 便于排查\n\n### 已知限制\n\n- cron 操作 (create/list/rm) 需 plugin 已装 (peer 由 plugin 维护)\n- knowledge POST 路径 v2 (opphub-web server 端合并待定)\n- refresh endpoint 探测待 plugin client 暴露\n\nFile v5.0.0:docs/runtime-channel-renderer-v31-design.md\n\n# opphub-skill v3.1 · OpenClaw runtime · channel-renderer 渲染层架构\n\n> \"原始内容归原始内容, 你得 写一个 飞书 card 的渲染层 当然未来也有其他通道需要特定的渲染\"\n\n> **含义**: runtime 渲染层是 OpenClaw 平台活, **不在 opphub skill v3.1 范围**\n> **本文状态**: 独立存档, 等 OpenClaw runtime 团队接\n\n---\n\n## 0. 文档归属\n\n| 项 | 归属 |\n|---|---|\n| **本档**(运行时渲染层) | OpenClaw runtime 团队 |\n| opphub skill v3.1 doc | skill 层 (我开干的范围) |\n| opphub-server schema v3.1 | server 层 (单独存档) |\n\n---\n\n\n> \"原始内容归原始内容, 你得 写一个 飞书 card 的渲染层 当然未来也有其他通道需要特定的渲染\"\n\n\n> \"只到 skill 开放完, 没开发完, 不会会话\"\n\n**含义**:\n- skill 层只发 \"IntentMessage 原始内容\"(channel-agnostic)\n- skill 层不拼飞书 card / Block Kit / InlineKeyboard\n- runtime 渲染层是 OpenClaw 平台活, **不在 opphub skill v3.1 范围**\n- server schema (`OpcKnowledgeEntry` 表) 也是单独一条线\n\n---\n\n\n| # | 任务 | 改动层 | 工时 | 依赖 |\n|---|---|---|---|---|\n| N10 | Skill 改造: 5 个 bin 都改用 `bot.skillApi.askInteractive/askFreeText/send`, 删硬编码飞书 JSON | Skill | 3h | 无 |\n| N11 | SKILL.md frontmatter: `requires.tools` 加 `minimax__web_search / web_fetch / minimax__understand_image / pdf / memory_search / wiki_search` | Skill | 0.5h | 无 |\n| N12 | OpenClaw runtime 新 API: `skillApi.send/askInteractive/askFreeText/getChannel` + 类型定义 + 8 个 Renderer (FeishuCard / FeishuGroup / WeixinText / SlackBlock / DiscordEmbed / TelegramInline / CliInquirer / WebForm) + FallbackText | Runtime | 8h | 无 (这是 OpenClaw runtime 改造, 不是 skill 改造) |\n| N13 | E2E 多 channel 测试: 飞书 DM / 飞书群 / 微信 iLink / CLI 各跑一遍偶合配置 + 偶合画像 6 步 | 全链路 | 2h | N10 N12 |\n| **N10-N13 合计** | | | **13.5h ≈ 1.7 天** | |\n\n**总工时** v3.1: 19.5h (skill 6 步) + 13.5h (renderer) + 1h (server schema N4-N5) + 1h (server API N5) = **约 35h ≈ 4.5 天**\n\n\n---\n\n\n\n### 关键分工\n\n```\n┌─────────────────────────────────────────────────────┐\n│  Skill / Plugin 层                                  │\n│  发 \"原始内容\" (IntentMessage, channel-agnostic)   │\n│  - prompt / options / actions / inputHint / links   │\n└──────────────────┬──────────────────────────────────┘\n                   ↓\n┌─────────────────────────────────────────────────────┐\n│  OpenClaw runtime · Content Renderer 层 ★ 这里 ★    │\n│  每个 channel 一个 renderer:                         │\n│  · FeishuCardRenderer   (interactive card + actions) │\n│  · FeishuGroupRenderer  (同上,群 channel)            │\n│  · WeixinRenderer       (文本 + emoji 协议)          │\n│  · SlackRenderer        (Block Kit)                  │\n│  · DiscordRenderer      (Embed + Components)         │\n│  · TelegramRenderer     (InlineKeyboard)             │\n│  · CliRenderer          (inquirer.select / confirm)  │\n│  · WebRenderer          (HTML form, 走 opphub-web)   │\n│  · FallbackTextRenderer (纯文本兜底)                │\n└──────────────────┬──────────────────────────────────┘\n                   ↓\n                Channel (IM / CLI / Web)\n```\n\n### Skill / Plugin API (IntentMessage 原始内容)\n\n```ts\ninterface IntentMessage {\n  prompt: string;                    // 主文案 (\"选默认推送通道\")\n  options?: Array<{                  // 选项 (pickOne 场景)\n    id: string;                      // 选中时回传 (feishu:pm)\n    label: string;                   // 显示 (\"feishu:pm ⭐\")\n    hint?: string;                    // 副文案 (\"pm bot\")\n    isDefault?: boolean;\n  }>;\n  actions?: Array<{                  // 按钮\n    id: string;                      // 唯一 action id\n    label: string;                   // 按钮文字 (\"确认\" / \"🚀 一键装 plugin\")\n    style?: 'primary' | 'default' | 'danger';\n    payload?: Record<string, any>;   // action 回调带的元数据\n  }>;\n  inputHint?: string;                // 自由文本输入\n  links?: Array<{                    // 链接按钮 (install 引导)\n    text: string;\n    url: string;\n  }>;\n  markdown?: string;                 // 富文本 (lark_md / mrkdwn / html)\n  header?: {\n    title: string;\n    color?: 'blue' | 'orange' | 'red' | 'green';\n  };\n  metadata?: Record<string, any>;\n}\n```\n\n### 8 个 Renderer 渲染示例\n\n| channel | askInteractive / askFreeText 渲染 |\n|---|---|\n| 飞书 DM / 群 | interactive card · actions + button + url link |\n| 微信 iLink | 文本 + emoji + \"回复按钮序号\" + 文本链接 |\n| Slack | Block Kit · actions block + button + url |\n| Discord | Embed + Components (buttons + link) |\n| Telegram | InlineKeyboard + URL 按钮 |\n| CLI / tty | inquirer.select / confirm / input |\n| web (opphub-web) | HTML form (走 /admin/devices/:opcId) |\n| FallbackText | 纯文本兜底 (不识别的 channel) |\n\n### OpenClaw runtime skillApi 新增 API\n\n```ts\ndeclare global {\n  interface SkillApi {\n    /** 单向通知 (无操作) */\n    send(intent: IntentMessage): Promise<void>;\n\n    /** 交互意图 - 等用户回复 (pickOne / actions) */\n    askInteractive(intent: IntentMessage & {\n      options?: Option[];\n      actions?: Action[];\n    }): Promise<UserReply>;\n\n    /** 自由文本输入 */\n    askFreeText(intent: IntentMessage & {\n      inputHint: string;\n    }): Promise<string>;\n\n    /** 当前 channel hint */\n    getChannel(): {\n      type: 'feishu' | 'weixin' | 'slack' | 'discord' | 'telegram' | 'cli' | 'web';\n      account?: string;\n      supportsCards: boolean;\n      supportsInlineActions: boolean;\n      supportsFreeText: boolean;\n    };\n  }\n}\n```\n\n### Skill ↔ Plugin ↔ Runtime 三者边界 (钉死)\n\n| 层 | 谁做 | 不做 |\n|---|---|---|\n| Skill (per-invocation) | 发 \"IntentMessage 原始内容\" + 调 plugin CLI | ❌ 不拼飞书 card JSON / Block Kit / InlineKeyboard |\n| Plugin (常驻) | 执行 spawn CLI + WS 通讯 + 直读 JSON | ❌ 不接 channel context (没 channelType) |\n| OpenClaw runtime ★ | 把 IntentMessage 转译成 channel 原生渲染 | 不解析业务意图 |\n| Channel | 飞书 OpenAPI / 微信 iLink / Slack / Discord / Telegram / tty / web | — |\n\n\n- ✅ skill 必须调 `bot.skillApi.askInteractive(...)` / `askFreeText(...)` / `send(...)`\n- ❌ skill **永远不写** 硬编码 飞书 card JSON (不是 skill 的活)\n- ❌ skill **永远不写** `feishu_mcp_create_card(...)` 这种飞书 OpenAPI 直调\n- ❌ skill **永远不** 自己 `bot.sendMessage(一段 ad-hoc JSON)`\n- ✅ OpenClaw runtime 8 个 renderer, 未来加 channel 加 renderer 就行 (renderer 由 runtime/插件编写者实现)\n- ❌ skill / plugin **不在 tty** 跑 inquirer (runtime CliRenderer 才跑)\n- ✅ CliRenderer 是 runtime 内置 (SSH/headless fallback)\n\n### Plugin CLI 保留 (再钉一遍)\n\n- ❌ plugin **不接** skill 的 IntentMessage (没 channel context)\n- ❌ plugin CLI 的 inquirer 姿势 **保留** (SSH/headless 用户绕过 skill 直接跑 plugin CLI)\n\n### 渲染示例 (每个 channel 一段)\n\n#### 飞书 (FeishuCardRenderer)\n\n```json\n{\n  \"config\": { \"wide_screen_mode\": true },\n  \"header\": { \"title\": { \"tag\": \"plain_text\", \"content\": \"选默认推送通道\" }, \"template\": \"blue\" },\n  \"elements\": [\n    { \"tag\": \"div\", \"text\": { \"tag\": \"lark_md\", \"content\": \"本机已配 5 条有效通道\" } },\n    { \"tag\": \"select_static\", \"options\": [\n      { \"text\": { \"tag\": \"plain_text\", \"content\": \"feishu:pm\" }, \"value\": \"feishu:pm\" }\n    ], \"value\": { \"type\": \"text\", \"text\": \"feishu:pm\" } },\n    { \"tag\": \"action\", \"actions\": [\n      { \"tag\": \"button\", \"text\": { \"tag\": \"plain_text\", \"content\": \"确认\" }, \"type\": \"primary\", \"value\": { \"action\": \"confirm\" } },\n      { \"tag\": \"button\", \"text\": { \"tag\": \"plain_text\", \"content\": \"取消\" }, \"type\": \"danger\", \"value\": { \"action\": \"cancel\" } }\n    ]}\n  ]\n}\n```\n\n#### 微信 iLink (WeixinRenderer · 文本 + emoji)\n\n```\n📋 偶合 OppHub · 选默认推送通道\n\n本机已配 5 条有效通道:\n  1️⃣ feishu:default       公司主账号\n  2️⃣ feishu:dev            dev bot\n  3️⃣ ⭐ feishu:pm           pm bot\n  4️⃣ feishu:frontend       前端\n  5️⃣ openclaw-weixin:eat-im-bot   微信 bot\n\n回复 [数字] 选择通道, 或:\n  Y → 用默认 (feishu:pm)\n  N → 取消\n\n(消息已加密, 2 分钟有效)\n```\n\n#### Telegram (TelegramRenderer · InlineKeyboard)\n\n```json\n{\n  \"text\": \"📋 *偶合 OppHub · 选默认推送通道*\\n\\n本机已配 5 条有效通道\",\n  \"reply_markup\": {\n    \"inline_keyboard\": [\n      [{ \"text\": \"feishu:default\", \"callback_data\": \"pick:feishu:default\" }],\n      [{ \"text\": \"⭐ feishu:pm\",   \"callback_data\": \"pick:feishu:pm\" }],\n      [{ \"text\": \"feishu:dev\",     \"callback_data\": \"pick:feishu:dev\" }],\n      [{ \"text\": \"✅ 确认\",        \"callback_data\": \"confirm\" }, { \"text\": \"❌ 取消\", \"callback_data\": \"cancel\" }]\n    ]\n  }\n}\n```\n\n#### CLI / tty (CliRenderer · inquirer)\n\n```js\nconst answer = await inquirer.prompt([{\n  type: 'list',\n  name: 'choice',\n  message: '选默认推送通道',\n  choices: [\n    { name: 'feishu:default (公司主账号)', value: 'feishu:default' },\n    { name: '⭐ feishu:pm (pm bot)', value: 'feishu:pm' },\n    ...\n  ],\n  default: 'feishu:pm',\n}]);\n```\n\n#### Web (WebRenderer · HTML form, 走 opphub-web)\n\n```html\n<form action=\"/api/user/channels/default\" method=\"PATCH\">\n  <h2>选默认推送通道</h2>\n  <select name=\"channelType+channelId\">\n    <option value=\"feishu:default\">feishu:default</option>\n    <option value=\"feishu:pm\" selected>feishu:pm</option>\n    ...\n  </select>\n  <button type=\"submit\">确认</button>\n  <button type=\"cancel\">取消</button>\n</form>\n```\n\n### 改造 + 风险\n\n| 风险 | 缓解 |\n|---|---|\n| skill 误写硬编码飞书 card | SKILL.md 加 lint / require.js static check (CI grep) |\n| 现有 plugin CLI 用户绕开 skill | plugin CLI inquirer **完全保留** |\n| 微信 iLink 不支持原生 card | WeixinRenderer 走文本 + emoji + 编号协议 (兜底) |\n| tty 没 TTY 时 CliRenderer 失败 | FallbackTextRenderer 兜底 (纯文本提一个一次性的\"我帮你做\") |\n| renderer 自己 crash | renderer 异常时自动回退到 FallbackTextRenderer (try/catch 包裹) |\n\n### 关联档\n\n- 设计稿:`workspace/skills/opphub/docs/v3.1-architecture.md` (本节)\n- 后续整改:Skill 全部 bin 改用 `bot.skillApi.*` 替代硬编码 JSON\n\n---\n\nFile v5.0.0:docs/server-schema-v31-design.md\n\n# opphub-server schema v3.1 · 知识库改造设计\n\n> \"现在的 公司能力卡片, 是记录到数据库的, 是字段固定的, 但是我要改成开放式的, 整个录入的信息是存到知识库里, 也就是向量存储, 这样就不拘泥于字段\"\n\n> **含义**: server schema 改造是 opphub-server 团队活, **不在 opphub skill v3.1 范围**\n\n---\n\n## 0. 文档归属\n\n| 项 | 归属 |\n|---|---|\n| 本档 (server schema) | opphub-server 团队 |\n| opphub skill v3.1 doc | skill 层 (我开干的范围) |\n| OpenClaw runtime 渲染层 | runtime 团队 (单独存档) |\n\n---\n\n\n> \"现在的 公司能力卡片, 是记录到数据库的, 是字段固定的, 但是我要改成开放式的, 整个录入的信息是存到知识库里, 也就是向量存储, 这样就不拘泥于字段\"\n\n## 2. 与 7/15 蒸馏设计的关系\n\n- **7/15 web P0 蒸馏设计**: `OpcProfile.distilledJson` + `embedding vector(768)` + `lib/distill.ts` LLM 填空字段\n\n---\n\n#### 6.5 schema 改造 (新加 + 弱化)\n\n**新加 `OpcKnowledgeEntry`**:\n\n```prisma\nmodel OpcKnowledgeEntry {\n  id              String   @id @default(cuid())\n  opcId           String   @map(\"opc_id\")\n  sourceType      String   @map(\"source_type\") // rawText / url / upload / auto / manual\n  sourceUrl       String?  @map(\"source_url\")\n  rawText         String   @map(\"raw_text\") @db.Text\n  chunks          Json?    // [{text, start, end, embedding_ref}]\n  embedding       Unsupported(\"vector(768)\")?\n  embeddingModel  String?  @map(\"embedding_model\")\n  embeddingVersion Int?    @map(\"embedding_version\")\n  fulltext        Unsupported(\"tsvector\")?  // 全文索引\n  distilledTags   String[] @map(\"distilled_tags\") // LLM 抽, 可空, 可自举\n  visibility      String   @default(\"PRIVATE\") // PRIVATE / PUBLIC\n  createdAt       DateTime @default(now()) @map(\"created_at\")\n  updatedAt       DateTime @updatedAt @map(\"updated_at\")\n  deletedAt       DateTime? @map(\"deleted_at\")\n\n  opc OpcAccount @relation(fields: [opcId], references: [opcId])\n\n  @@index([opcId, deletedAt])\n  @@index([visibility])\n  @@map(\"opc_knowledge_entry\")\n}\n```\n\n**弱化 `OpcProfile`**(字段保留但**不强制填**,作为蒸馏快照):\n\n```prisma\nmodel OpcProfile {\n  // 砍: mainSkill, subSkills, experience, cases, priceMin, priceMax, differentiators\n  // 留: distilledJson (快照, LLM 蒸馏结果, 可空)\n  // 留: embedding + embeddingModel + embeddingVersion (跨 entry 聚合向量)\n  // 新加: knowledgeCount Int @default(0)  // 关联多少条 OpcKnowledgeEntry\n  // 新加: lastKnowledgeAt DateTime?        // 最近一次入知识库\n}\n```\n\n**弱化 `OpcSkillCard`**(从\"多张字段卡\"改成\"多 entry 的视图\"):\n\n```prisma\n// 方案 A: 保留 OpcSkillCard 表, 但字段全是可空, 只作为展示快照\n// 方案 B: 直接砍 OpcSkillCard, 改用 OpcKnowledgeEntry 聚合\n```\n\n**全文索引 + 向量索引**(§7 决策已钉,7/14 拍):\n- pgvector HNSW: `m=16, ef=64`(7/14 拍)\n- tsvector: `to_tsvector('simple', rawText)`,GIN 索引\n- 检索: `embedding <=> query_embedding` + `fulltext @@ to_tsquery('simple', query_text)`,**完全不需要 JOIN 表**\n\n\n---\n\n#### 6.6 检索姿势 (matching 时)\n\n```sql\n-- 候选 OPC 召回 (Stage 1: 向量召回)\nSELECT e.opcId, e.id AS entryId, e.rawText,\n       e.embedding <=> $1::vector AS distance,\n       e.distilledTags\nFROM opc_knowledge_entry e\nWHERE e.deletedAt IS NULL\n  AND e.visibility = 'PUBLIC'\nORDER BY e.embedding <=> $1::vector\nLIMIT 50;\n\n-- 二次过滤 (Stage 2: 全文 + tag 过滤, 不 JOIN 表)\nSELECT * FROM (\n  -- 上面的结果\n) candidates\nWHERE candidates.fulltext @@ to_tsquery('simple', $2)\n  AND candidates.distilledTags && $3::text[];  -- 任意 tag 命中\n\n-- LLM 精排 (Stage 3: 跟 7/15 设计同)\n```\n\nFile v5.0.0:docs/server-schema-v32-design.md\n\n# server schema v3.2 设计 · idempotent ingest\n\n>\n> 关联 workboard: `v3.3-skill-server-separation-2026-07-20` (id=bc34d33f-...)\n>\n> **跟 v3.1 关系**: v3.1 schema 弱化了 OpcProfile/OpcSkillCard，加了 OpcKnowledgeEntry;\n> v3.2 在 OpcKnowledgeEntry 基础上加 idempotent ingest 能力，**不动 v3.1 已拍的结构**。\n\n---\n\n\n\n1. **无去重**：server `OpcKnowledgeEntry.create()` 直接 create，0 个 unique constraint（除 entry.id 主键）\n   - 同 OPC 同 rawText 提交 2 次 → 2 条 entry\n\n2. **无幂等键**：改一个错别字只能新建 entry，原 entry 还在\n   - 没 `idempotencyKey` 概念\n   - 没 `contentHash` 概念\n\n3. **无冲突检测**：录入有矛盾字段时静默入库\n   - 没版本管理 / source 优先级 / 时间戳覆盖策略\n\n---\n\n\n- **P1**: skill = 采集者 + 翻译者，只产 rawText。**不做任何去重 / 冲突判断 / 版本管理**\n- **P2**: server = 仓库 + 处理器，做去重 / 冲突检测 / 版本管理 / 蒸馏 / 嵌入 / 召回\n- **P3**: 幂等性 + 冲突处理都在 server 端按 **content-addressable + idempotencyKey** 双重保险\n\n---\n\n## 三、Schema 改造 (最小)\n\n### 3.1 OpcKnowledgeEntry 加字段\n\n```prisma\nmodel OpcKnowledgeEntry {\n  // 现有字段 (v3.1) - 不动\n  id           String  @id @default(cuid())\n  opcId        String  @map(\"opc_id\")\n  sourceType   String  @map(\"source_type\") // rawText / url / upload / auto / manual\n  sourceUrl    String? @map(\"source_url\")\n  rawText      String  @map(\"raw_text\") @db.Text\n  status       String  @default(\"pending\") // pending / processing / done / failed\n  chunkSummary Json?   @map(\"chunk_summary\")\n  embeddingModel   String? @map(\"embedding_model\")\n  embeddingVersion Int?    @map(\"embedding_version\")\n  distilledTags String[] @default([]) @map(\"distilled_tags\")\n  visibility    String   @default(\"PRIVATE\")\n  retryCount Int     @default(0) @map(\"retry_count\")\n  lastError  String? @map(\"last_error\")\n  createdAt DateTime  @default(now()) @map(\"created_at\")\n  updatedAt DateTime  @updatedAt @map(\"updated_at\")\n  deletedAt DateTime? @map(\"deleted_at\")\n\n  \n  // idempotencyKey: SHA256(opcId + entryType + entryDimension)\n  //   - 同 OPC 同 type+dimension → 同 key → server 走 upsert 路径\n  //   - 改 rawText (改一个错别字) → contentHash 变但 key 不变 → server 比 hash\n  idempotencyKey  String? @map(\"idempotency_key\")\n  \n  // contentHash: SHA256(rawText) 当前内容指纹\n  //   - 用来判断 rawText 是否真变了\n  contentHash     String? @map(\"content_hash\")\n  \n  // entryType + entryDimension: skill 拆出来的 4 类卡 (ability/upstream/downstream/peer) + 维度名\n  //   - 这 2 个字段是去重的最低必要锚点, 不属于\"过度字段化\"\n  //   - skill knowledge-card 拆出来直接传过来, server 不解析 rawText 前缀\n  entryType       String? @map(\"entry_type\")        // ability / upstream / downstream / peer\n  entryDimension  String? @map(\"entry_dimension\")   // 达人营销 / KOL 资源 / 品牌方 / ...\n  \n  // previousEntryId: 软链 (被覆盖的旧 entry), 保留 history\n  //   - 跟 git 同款: 老 entry 标 supersededAt, 新 entry previousEntryId 链回老 entry\n  //   - 召回时不查 supersededAt != null 的 entry\n  previousEntryId String? @map(\"previous_entry_id\")\n  supersededAt    DateTime? @map(\"superseded_at\")   // 非 null 即被新 entry 取代\n  \n  opc    OpcAccount          @relation(fields: [opcId], references: [opcId])\n  chunks OpcKnowledgeChunk[] @relation(\"EntryChunks\")\n  \n  @@unique([opcId, idempotencyKey])                    // 幂等唯一键\n  @@index([opcId, entryType, entryDimension])          // 检索\n  @@index([opcId, deletedAt])                          // 现有\n  @@index([supersededAt])                              // 召回过滤\n}\n```\n\n\n\n**v3.2 加的字段**:\n| 字段 | 是否破坏\"开放式\" |\n|---|---|\n| `idempotencyKey` | ❌ 不破坏。skill 算 hash 传过来, server 不解析内容 |\n| `contentHash` | ❌ 不破坏。纯 hash, 不存业务语义 |\n| `previousEntryId` + `supersededAt` | ❌ 不破坏。跟 git 同款, 不删 history |\n\n**核心**: rawText 仍然全量存 (这是 12:58 拍的精神), 加的结构化字段都是**辅助字段** (去重 + 索引 + 版本链), 不替代 rawText。\n\n---\n\n## 四、接口改造\n\n### 4.1 新接口: `POST /api/knowledge/ingest` v2 (idempotent)\n\n**位置**: `/app/api/user/knowledge/ingest/route.ts` (现有 v1 接口保留, 加 v2 路由或加 query param `?v=2`)\n\n**request body**:\n```json\n{\n  \"opcId\": \"opc_1hz6wsjrmt1s\",\n  \"rawText\": \"睿驰嘉禾 · 能力卡片 · 达人营销\\n\\n(证据: rawText 包含 \\\"达人营销\\\")\\n...\",\n  \"entryType\": \"ability\",\n  \"entryDimension\": \"达人营销\",\n  \"idempotencyKey\": \"sha256_hash\",\n  \"contentHash\": \"sha256_hash\"\n}\n```\n\n**response 4 种**:\n```json\n// 1. 新增成功\n{ \"ok\": true, \"action\": \"created\", \"entryId\": \"cmxxx\", \"chunkCount\": null, \"status\": \"pending\" }\n\n// 2. 幂等命中 (rawText 没变)\n{ \"ok\": true, \"action\": \"no_change\", \"entryId\": \"cmxxx\", \"message\": \"rawText 与已有 entry 完全一致\" }\n\n// 3. 软链覆盖 (rawText 变了但无关键字段冲突)\n{ \n  \"ok\": true, \n  \"action\": \"soft_chain_override\", \n  \"entryId\": \"cmyyy\",                    // 新 entry\n  \"previousEntryId\": \"cmxxx\",            // 被取代的旧 entry\n  \"supersededEntry\": { \"id\": \"cmxxx\", \"supersededAt\": \"...\" }\n}\n\n// 4. 冲突返报告 (rawText 变了 + 关键字段冲突, 不入库, 等用户拍)\n{ \n  \"ok\": false, \n  \"conflict\": true, \n  \"conflictReport\": {\n    \"entryId\": \"cmxxx\",                  // 已有 entry\n    \"diffType\": \"key_field_conflict\"     // vs \"incremental_addition\"\n  }\n}\n```\n\n### 4.2 实现逻辑 (server 端伪代码)\n\n```typescript\n// app/api/user/knowledge/ingest/route.ts v2\nexport async function POST(req: NextRequest) {\n  const opcId = getOpcId(req);\n  if (!opcId) return unauthorized();\n  \n  const { rawText, entryType, entryDimension, idempotencyKey, contentHash } = await req.json();\n  if (!rawText || !idempotencyKey || !contentHash) return missingArgs();\n  \n  // 1. 查 unique\n  const existing = await prisma.opcKnowledgeEntry.findUnique({\n    where: { opcId_idempotencyKey: { opcId, idempotencyKey } }\n  });\n  \n  // 2. 不存在 → 直接 create\n  if (!existing) {\n    const entry = await prisma.opcKnowledgeEntry.create({\n      data: { opcId, rawText, entryType, entryDimension, \n              idempotencyKey, contentHash, status: 'pending' }\n    });\n    return { ok: true, action: 'created', entryId: entry.id };\n  }\n  \n  // 3. 存在 → 比 contentHash\n  if (existing.contentHash === contentHash) {\n    // rawText 没变, 静默幂等命中\n    return { ok: true, action: 'no_change', entryId: existing.id };\n  }\n  \n  // 4. contentHash 变 → 比 rawText 关键字段\n  const diff = diffRawText(existing.rawText, rawText);\n  \n  if (diff.hasKeyFieldConflict) {\n    // 关键字段冲突 → 返 conflictReport, 不入库\n    return { \n      ok: false, \n      conflict: true, \n      conflictReport: {\n        entryId: existing.id,\n        oldRawText: existing.rawText,\n        newRawText: rawText,\n        conflictFields: diff.conflictFields,\n        diffType: 'key_field_conflict'\n      }\n    };\n  }\n  \n  // 5. 无关键字段冲突 → 软链覆盖\n  // 5a. 老 entry 标 superseded\n  await prisma.opcKnowledgeEntry.update({\n    where: { id: existing.id },\n    data: { supersededAt: new Date() }\n  });\n  // 5b. 新 entry, previousEntryId 链回老 entry\n  const newEntry = await prisma.opcKnowledgeEntry.create({\n    data: { \n      opcId, rawText, entryType, entryDimension, \n      idempotencyKey, contentHash, \n      previousEntryId: existing.id,\n      status: 'pending'\n    }\n  });\n  return { \n    ok: true, \n    action: 'soft_chain_override',\n    entryId: newEntry.id,\n    previousEntryId: existing.id,\n    supersededEntry: { id: existing.id, supersededAt: new Date().toISOString() }\n  };\n}\n```\n\n### 4.3 冲突检测算法 `diffRawText`\n\n```typescript\n// lib/diff-raw-text.ts\nfunction diffRawText(oldText: string, newText: string) {\n  // 1. 提取关键字段 (正则 + 简单启发式, 不调 LLM)\n  const oldFields = extractKeyFields(oldText);\n  const newFields = extractKeyFields(newText);\n  \n  // 2. 比对关键字段\n  const conflictFields: string[] = [];\n  for (const key of ['legal_person', 'registered_capital', 'business_direction', 'industry']) {\n    if (oldFields[key] && newFields[key] && oldFields[key] !== newFields[key]) {\n      conflictFields.push(`${key}: ${oldFields[key]} → ${newFields[key]}`);\n    }\n  }\n  \n  // 3. 关键字段冲突 vs 增量补充\n  const hasKeyFieldConflict = conflictFields.length > 0;\n  \n  return { \n    hasKeyFieldConflict, \n    conflictFields,\n    diffType: hasKeyFieldConflict ? 'key_field_conflict' : 'incremental_addition'\n  };\n}\n\n// 简单启发式提取关键字段 (不调 LLM, 性能优先)\nfunction extractKeyFields(rawText: string) {\n  const fields: Record<string, string> = {};\n  \n  const legalPersonMatch = rawText.match(/法[定]?人[::]?\\s*([^\\s,，;；\\n]+)/);\n  if (legalPersonMatch) fields.legal_person = legalPersonMatch[1];\n  \n  if (bossMatch) fields.legal_person = bossMatch[1];\n  \n  // 注册资本\n  const capitalMatch = rawText.match(/注册资[本金][::]?\\s*([0-9,，.]+\\s*[万亿]?)/);\n  if (capitalMatch) fields.registered_capital = capitalMatch[1];\n  \n  // 业务方向 (取第一条)\n  const businessMatch = rawText.match(/业务方向[::]?\\s*([^\\n]+)/);\n  if (businessMatch) fields.business_direction = businessMatch[1];\n  \n  return fields;\n}\n```\n\n### 4.4 召回过滤改动 (search 路由)\n\n```typescript\n// app/api/user/knowledge/search/route.ts 改动\nconst entries = await prisma.opcKnowledgeEntry.findMany({\n  where: { \n    opcId, \n    deletedAt: null,\n    supersededAt: null,  // ← 新加: 召回时不查被取代的 entry\n  },\n  // ... 其他\n});\n```\n\n---\n\n## 五、Skill 端改动 (v3.3 skill 范围)\n\n### 5.1 新加 `bin/opphub-knowledge-submit.js`\n\n**职责**: 把 cards 提交给 server, 接收 conflictReport, **不做任何判断**\n\n```javascript\n#!/usr/bin/env node\n// bin/opphub-knowledge-submit.js · v3.3.0\n// \n// 本 bin 纯转发 + 透传 server 响应\n//\n// 输入: cards.json (knowledge-card 输出)\n// 输出: { submitted: [...], deduplicated: [...], conflicts: [...] }\n\nimport { readFileSync } from \"node:fs\";\nimport { createHash } from \"node:crypto\";\nimport { readToken } from \"../lib/opphub-plugin-client.js\";\n\nconst API_BASE = \"https://api.opphub.ruiplus.cn\";\n\nfunction sha256(s) {\n  return createHash(\"sha256\").update(s, \"utf8\").digest(\"hex\");\n}\n\nasync function main() {\n  const args = parseArgs(process.argv.slice(2));\n  const cards = JSON.parse(readFileSync(args.cards, \"utf8\"));\n  \n  // 拿 token (plugin = source of truth, 14:21 拍)\n  const tokenDoc = await readToken();\n  const accessToken = tokenDoc?.access_token;\n  if (!accessToken) {\n    return out({ ok: false, error: \"no_token\", message: \"需要先偶合登录\" });\n  }\n  \n  // 从 token 解 opcId (skill 不需要 opphub-server 二次确认)\n  const opcId = decodeJwt(accessToken).opcId;\n  \n  const results = { submitted: [], deduplicated: [], conflicts: [] };\n  \n  for (const card of cards) {\n    const idempotencyKey = sha256(`${opcId}|${card.type}|${card.dimension}`);\n    const contentHash = sha256(card.text);\n    \n    const resp = await fetch(`${API_BASE}/api/knowledge/ingest`, {\n      method: \"POST\",\n      headers: { \n        \"Authorization\": `Bearer ${accessToken}`, \n        \"Content-Type\": \"application/json\" \n      },\n      body: JSON.stringify({\n        opcId, \n        rawText: card.text, \n        entryType: card.type, \n        entryDimension: card.dimension, \n        idempotencyKey, \n        contentHash\n      })\n    });\n    const data = await resp.json();\n    \n    if (data.ok && data.action === \"no_change\") {\n      results.deduplicated.push({ cardIndex: card.index, entryId: data.entryId });\n    } else if (data.ok) {\n      // created 或 soft_chain_override 都算 \"submitted\"\n      results.submitted.push({ \n        cardIndex: card.index, \n        entryId: data.entryId,\n        action: data.action,\n        previousEntryId: data.previousEntryId || null\n      });\n    } else if (data.conflict) {\n      results.conflicts.push({ \n        cardIndex: card.index, \n        conflictReport: data.conflictReport \n      });\n    }\n  }\n  \n  out({\n    ok: true,\n    opcId,\n    summary: {\n      submitted: results.submitted.length,\n      deduplicated: results.deduplicated.length,\n      conflicts: results.conflicts.length\n    },\n    submitted: results.submitted,\n    deduplicated: results.deduplicated,\n    conflicts: results.conflicts,\n    nextStep: results.conflicts.length > 0 \n      ? \"用 bot.skillApi.askInteractive 让用户拍冲突项\" \n      : \"全部成功, 跑 knowledge-match\"\n  });\n}\n\nmain().catch(e => out({ ok: false, error: e?.message ?? String(e) }));\n```\n\n### 5.2 改 `bin/opphub-knowledge-ingest-batch.js`\n\n**改动**: 删\"循环调 knowledge-add\"的逻辑, 改成\"调 submit\"\n\n```javascript\nfor (const card of cards) {\n  await opphub-knowledge-add --raw-text \"$card.text\" --source-type auto\n}\n\n// 新逻辑 (v3.3)\nopphub-knowledge-submit --cards cards.json\n```\n\ningest-batch 变成纯编排入口 (不做实际提交), 实际提交逻辑在 submit bin。\n\n### 5.3 不动的部分 (skill 端)\n\n- ✅ `knowledge-card` 不动 — 仍然按 MCN/SaaS 模板拆 card\n- ✅ `knowledge-discover` 不动 — 仍然产 queryPlan / 验 rawText\n- ✅ `knowledge-search` 不动 — 召回接口跟 server 同步过滤 superseded\n- ✅ `knowledge-relate` 不动 — xls 解析逻辑\n\n---\n\n## 六、Bot 端流程改造 (v3.3)\n\n按 SKILL.md §录入公司流程 阶段 4-5，bot 拿到 `knowledge-submit` 的结果:\n\n```\nbot 给你看:\n📋 录入睿驰嘉禾 · 结果报告\n\n✅ 新提交 7 条\n🔁 已有 3 条 (跳过)\n⚠️ 冲突 1 条 (要你拍):\n  - 能力卡片 · 虚拟人 IP 孵化\n    老: 数字人形象设计\n    新: 虚拟主播代运营\n    冲突字段: 业务描述\n\n回 \"保留旧的\" / \"用新的\" / \"跳过\"\n```\n\n**bot 走 IntentMessage**:\n- `askInteractive` 列出冲突项 + 让用户点选\n- 用户选择后, bot 再调一次 submit (不传冲突项的 cards, 或传 `forceOverride: true`)\n\n**冲突覆盖流程**:\n```\n用户: 用新的\nbot: 收到, 重新提交 1 条 (跳过冲突检测)\nbot 调: opphub-knowledge-submit --cards cards.json --force-override-conflict --json\nserver: 跳过冲突检测, 强制 soft_chain_override\n```\n\n---\n\n## 七、部署 & 测试\n\n\n1. ✅ **本地 dev migrate** (不部署 ECS)\n2. ✅ server 端 schema 改造 + 接口实现 (本地 dev server)\n3. ✅ skill 端 submit + ingest-batch 改造 (本地开发)\n\n### 7.2 本地 E2E 测试 (mock server)\n\n```bash\n# 1. 启本地 mock server\nnode tests/mock-knowledge-server.js &\n\n# 2. 跑 skill 端 (用本地 mock URL)\nOPPHUB_API_BASE=http://localhost:4001 bin/opphub knowledge-submit --cards /tmp/cards.json\n\n# 3. 验证 4 种响应:\n#    - 新增成功 (action: created)\n#    - 幂等命中 (action: no_change)\n#    - 软链覆盖 (action: soft_chain_override)\n#    - 冲突报告 (conflict: true)\n```\n\n### 7.3 回滚\n\n- server schema 改造加字段是**可空**的 (idempotencyKey? contentHash? entryType? entryDimension? previousEntryId? supersededAt?)\n- 老 entry 没这 6 个字段 → 不影响老流程\n- 新 entry 没 idempotencyKey → 走\"非幂等路径\" (跟 v3.1 行为一致)\n- **回滚安全**: 直接撤 server 代码, schema 不动\n\n---\n\n## 八、不动的事 (反复钉的纪律)\n\n- ❌ 不动 plugin 仓 (plugin-cli-cleanup-2026-07-20 单独 backlog)\n\n---\n\n## 九、关联文档\n\n- **设计稿 (本文)**: `workspace/skills/opphub/docs/server-schema-v32-design.md`\n- **v3.1 设计稿**: `workspace/skills/opphub/docs/server-schema-v31-design.md` (平行, 不冲突)\n- **v3.1 架构**: `workspace/skills/opphub/docs/v3.1-architecture.md`\n- **运行时渲染层**: `workspace/skills/opphub/docs/runtime-channel-renderer-v31-design.md` (不动)\n- **workboard 卡**: `v3.3-skill-server-separation-2026-07-20` (id=bc34d33f-...)\n- **MEMORY 红线**: `~/.openclaw/workspace-dev/MEMORY.md` §v3.3 backlog\n\n---\n\n## 十、下次同样场景\n\nFile v5.0.0:docs/superpowers/plans/2026-07-23-phase1-nav-rewrite.md\n\n# OppHub 阶段 1：导航 + 角色表达改造实施计划\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** 把 opphub-web 当前的 7 个一级导航改造为 spec 里的 6 个 (`dashboard / knowledge / discover / matches / orders / wallet`), 移除路径重复 (`/me/capabilities` ↔ `/opc/profile`, `/demands` ↔ `/marketplace`), 旧路径 301 跳新路径\n\n**Architecture:** 保持 Next.js app router 不变, 只移文件 / 改名 / 加 redirect; 数据接口 (`/api/knowledge` 等) 不动, 这样改动可单独发版\n\n**Tech Stack:** Next.js 14 app router, TypeScript, `next.config.js` `redirects()`\n\n---\n\n## 总体任务地图\n\n```\nTask 1: 文件迁移 + 旧路径 301 跳新路径\n  - /me/capabilities → /knowledge\n  - /opc/profile → /knowledge/[id]\n  - /marketplace → /discover?role=seller\n  - /demands → /discover?role=buyer\n  - /match → /matches\n\nTask 2: 新建 /knowledge, /discover, /matches (默认重定向列表)\n\nTask 3: TopBar 改 6 个一级导航\n\nTask 4: 验证 (本地 build + 浏览器手测)\n\nTask 5: 部署 ECS + 终验\n```\n\n---\n\n## Task 1: 加 next.config.js redirects 套所有旧路径\n\n**Files:**\n- Modify: `next.config.js` (新增 redirects 段, 若不存在则创建)\n- Test: curl 各旧路径能 301 到新路径\n\n- [ ] **Step 1: 读现有 next.config.js**\n\n```bash\ntest -f opphub-web/next.config.js && cat opphub-web/next.config.js || echo \"NOT_EXISTS\"\n```\n\n预期: 输出现有内容 (或 `NOT_EXISTS`)\n\n- [ ] **Step 2: 添加 redirects 段**\n\n文件 `opphub-web/next.config.js`:\n\n```js\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  // v3.4 spec 阶段 1: 老路径 301 到新导航 (录→撮→谈→成)\n  async redirects() {\n    return [\n      { source: \"/me/capabilities\", destination: \"/knowledge\", permanent: true },\n      { source: \"/opc/profile\", destination: \"/knowledge/profile\", permanent: true },\n      { source: \"/marketplace\", destination: \"/discover?role=seller\", permanent: true },\n      { source: \"/demands\", destination: \"/discover?role=buyer\", permanent: true },\n      { source: \"/match\", destination: \"/matches\", permanent: true },\n    ];\n  },\n};\n\nmodule.exports = nextConfig;\n```\n\n如果原本有配置就把 redirects 段塞进顶层 const 内 (保留原 reactStrictMode、images 等其他配置项)。\n\n- [ ] **Step 3: 静态检查**\n\n```bash\ncd opphub-web && node -e \"console.log(JSON.stringify(require('./next.config.js'), null, 2))\" | head -40\n```\n\n预期: JSON 包含 `redirects` 是 array of 5 项\n\n- [ ] **Step 4: Commit**\n\n```bash\ncd opphub-web\ngit add next.config.js\ngit commit -m \"feat(nav): 旧导航路径 301 到新路径 (阶段 1)\"\n```\n\n---\n\n## Task 2: 新建 `/knowledge` 主页面 (Tab 表达 4 种角色)\n\n**Files:**\n- Create: `app/knowledge/page.tsx`\n- Create: `app/knowledge/profile/page.tsx` (替代 `/opc/profile`)\n- Create: `app/knowledge/[id]/page.tsx` (替代 `/opc/profile/[opcId]` 后续用)\n\n- [ ] **Step 1: 新建 knowledge 主页 (空壳)**\n\n文件 `app/knowledge/page.tsx`:\n\n```tsx\n\"use client\";\nimport { useState } from \"react\";\nimport TopBar from \"../_components/TopBar\";\n\ntype Tab = \"ability\" | \"downstream\" | \"upstream\" | \"peer\";\n\nconst TAB_LABEL: Record<Tab, string> = {\n  ability: \"我能提供\",\n  downstream: \"我在找\",\n  upstream: \"我的依赖\",\n  peer: \"同行关系\",\n};\n\nconst TAB_DESC: Record<Tab, string> = {\n  ability: \"录入你的能力/服务, AI 帮你找到需求方\",\n  downstream: \"录入你想找的东西, AI 帮你找到供应方\",\n  upstream: \"你依赖的供应商/数据源/工具\",\n  peer: \"同业联盟 / 同行参考 / 关联企业\",\n};\n\nexport default function KnowledgePage() {\n  const apiBase = process.env.NEXT_PUBLIC_API_BASE || \"\";\n  const [mounted, setMounted] = useState(false);\n  const [tab, setTab] = useState<Tab>(\"ability\");\n  const [opcId, setOpcId] = useState<string>(\"\");\n  const [entries, setEntries] = useState<{ id: string; dimension: string; rawText: string; updatedAt: string }[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  React.useEffect(() => setMounted(true), []);\n  React.useEffect(() => {\n    if (!mounted || !opcId) return;\n    setLoading(true);\n    const token = localStorage.getItem(\"opphubToken\") || \"\";\n    fetch(`${apiBase}/api/knowledge?opcId=me&entryType=${tab}`, {\n      headers: { authorization: \"Bearer \" + token },\n    })\n      .then((r) => r.json())\n      .then((j) => setEntries(j?.ok && Array.isArray(j.data) ? j.data : []))\n      .catch(() => setEntries([]))\n      .finally(() => setLoading(false));\n  }, [mounted, opcId, tab, apiBase]);\n\n  React.useEffect(() => {\n    try { setOpcId(localStorage.getItem(\"opcId\") || \"\"); } catch {}\n  }, []);\n\n  if (!mounted) {\n    return (\n      <main style={{ minHeight: \"100vh\", background: \"linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%)\" }}>\n        <TopBar current=\"knowledge\" />\n        <div style={{ maxWidth: 1200, margin: \"0 auto\", padding: 24, color: \"white\" }}>加载中...</div>\n      </main>\n    );\n  }\n\n  return (\n    <main style={{ minHeight: \"100vh\", background: \"linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%)\" }}>\n      <TopBar current=\"knowledge\" />\n      <div style={{ maxWidth: 1200, margin: \"0 auto\", padding: 24 }}>\n        <h1 style={{ color: \"white\", fontSize: 24, margin: \"0 0 6px\" }}>📇 知识库</h1>\n        <p style={{ color: \"rgba(255,255,255,0.85)\", fontSize: 13, margin: \"0 0 16px\" }}>\n          在聊天窗口 @偶合 说一句话录入, 或在这里浏览你已录入的条目\n        </p>\n\n        {/* Tabs */}\n        <div style={{ display: \"flex\", gap: 4, marginBottom: 12, flexWrap: \"wrap\" }}>\n          {(Object.keys(TAB_LABEL) as Tab[]).map((t) => (\n            <button\n              key={t}\n              onClick={() => setTab(t)}\n              style={{\n                padding: \"8px 16px\",\n                borderRadius: 8,\n                border: \"none\",\n                cursor: \"pointer\",\n                background: tab === t ? \"white\" : \"rgba(255,255,255,0.2)\",\n                color: tab === t ? \"#4f46e5\" : \"white\",\n                fontWeight: tab === t ? 600 : 400,\n              }}\n            >\n              {TAB_LABEL[t]}\n            </button>\n          ))}\n        </div>\n\n        <p style={{ color: \"rgba(255,255,255,0.7)\", fontSize: 13, marginBottom: 16 }}>\n          {TAB_DESC[tab]}\n        </p>\n\n        {/* List */}\n        {loading ? (\n          <div style={{ background: \"white\", borderRadius: 12, padding: 32, textAlign: \"center\", color: \"#6b7280\" }}>⏳ 加载中...</div>\n        ) : entries.length === 0 ? (\n          <EmptyState tab={tab} />\n        ) : (\n          <div style={{ display: \"flex\", flexDirection: \"column\", gap: 10 }}>\n            {entries.map((e) => <EntryCard key={e.id} entry={e} />)}\n          </div>\n        )}\n      </div>\n    </main>\n  );\n}\n\nfunction EmptyState({ tab }: { tab: Tab }) {\n  return (\n    <div style={{ background: \"white\", borderRadius: 12, padding: 48, textAlign: \"center\" }}>\n      <div style={{ fontSize: 48, marginBottom: 8 }}>📇</div>\n      <div style={{ fontSize: 16, color: \"#1f2937\", marginBottom: 8 }}>\n        {tab === \"ability\" && \"你还没说能干啥\"}\n        {tab === \"downstream\" && \"你还没说要找啥\"}\n        {tab === \"upstream\" && \"还没录入上游依赖\"}\n        {tab === \"peer\" && \"还没录入同行关系\"}\n      </div>\n      <div style={{ fontSize: 13, color: \"#6b7280\", marginBottom: 16 }}>\n        去聊天窗口 @偶合 说\"<span style={{ color: \"#4f46e5\" }}>{tab === \"ability\" ? \"录入我的服务\" : tab === \"downstream\" ? \"我需要 xxx\" : tab === \"upstream\" ? \"录入我的供应商\" : \"录入同行\"}</span>\" 开始\n      </div>\n    </div>\n  );\n}\n\nfunction EntryCard({ entry }: { entry: { id: string; dimension: string; rawText: string; updatedAt: string } }) {\n  const preview = entry.rawText.slice(0, 200);\n  return (\n    <a href={`/knowledge/${entry.id}`} style={{ textDecoration: \"none\" }}>\n      <div style={{ background: \"white\", borderRadius: 12, padding: 16, cursor: \"pointer\" }}>\n        <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\", marginBottom: 6 }}>{entry.dimension}</div>\n        <div style={{ fontSize: 13, color: \"#4b5563\", lineHeight: 1.6, marginBottom: 6, whiteSpace: \"pre-wrap\" }}>{preview}{entry.rawText.length > 200 ? \"...\" : \"\"}</div>\n        <div style={{ fontSize: 11, color: \"#9ca3af\" }}>更新: {new Date(entry.updatedAt).toLocaleString(\"zh-CN\")}</div>\n      </div>\n    </a>\n  );\n}\n```\n\n注意: `React.useEffect` 那一段用 `useEffect` (不要写 `React.useEffect`), 顶部要加 `import { useEffect, useState } from \"react\";`\n\n(此步只是把 API 数据展示出来, 编辑功能暂未接入; 详细录入正反馈/录入三选一按钮留到阶段 2-3)\n\n- [ ] **Step 2: 新建 knowledge/profile 替代 /opc/profile**\n\n文件 `app/knowledge/profile/page.tsx`:\n\n```tsx\n\"use client\";\nimport { Suspense, useEffect, useState } from \"react\";\nimport Link from \"next/link\";\nimport TopBar from \"../../_components/TopBar\";\nimport { useSearchParams } from \"next/navigation\";\n\nexport const dynamic = \"force-dynamic\";\n\ntype Company = {\n  companyName?: string;\n  companyType?: string;\n  legalPerson?: string;\n  teamSize?: number;\n  creditCode?: string;\n  address?: string;\n};\ntype Profile = {\n  mainSkill?: string;\n  subSkills?: string[];\n  experience?: string;\n  priceMin?: number;\n  priceMax?: number;\n  city?: string;\n  trustScore?: number;\n  completedOrders?: number;\n};\n\nexport default function KnowledgeProfilePage() {\n  return (\n    <Suspense fallback={<div style={{ color: \"white\", padding: 24 }}>加载中...</div>}>\n      <ProfileInner />\n    </Suspense>\n  );\n}\n\nfunction ProfileInner() {\n  const apiBase = process.env.NEXT_PUBLIC_API_BASE || \"\";\n  const sp = useSearchParams();\n  const opcIdQuery = sp?.get(\"opcId\");\n  const [mounted, setMounted] = useState(false);\n  const [me, setMe] = useState<{ opcId?: string } | null>(null);\n  const [targetOpc, setTargetOpc] = useState<string | null>(null);\n  const [company, setCompany] = useState<Company | null>(null);\n  const [profile, setProfile] = useState<Profile | null>(null);\n\n  useEffect(() => { setMounted(true); }, []);\n  useEffect(() => {\n    try { setMe({ opcId: localStorage.getItem(\"opcId\") || undefined }); } catch {}\n  }, []);\n\n  useEffect(() => {\n    if (!mounted || !me) return;\n    const target = !opcIdQuery || opcIdQuery === me.opcId ? me.opcId! : opcIdQuery;\n    setTargetOpc(target);\n    const token = localStorage.getItem(\"opphubToken\") || \"\";\n    if (target === me.opcId) {\n      fetch(`${apiBase}/api/auth/me`, { headers: { authorization: \"Bearer \" + token } })\n        .then((r) => r.json())\n        .then((j) => setProfile(j?.profile ?? null))\n        .catch(() => {});\n    } else {\n      fetch(`${apiBase}/api/opc/${target}/profile`, { headers: { authorization: \"Bearer \" + token } })\n        .then((r) => r.json())\n        .then((j) => setProfile(j?.profile ?? null))\n        .catch(() => {});\n    }\n  }, [mounted, me, opcIdQuery, apiBase]);\n\n  if (!mounted || targetOpc === null) {\n    return <main style={{ minHeight: \"100vh\", background: \"#f5f3ff\" }}><TopBar current=\"knowledge/profile\" /><div style={{ padding: 32 }}>⏳ 加载中...</div></main>;\n  }\n\n  const isMe = me?.opcId === targetOpc;\n  return (\n    <main style={{ minHeight: \"100vh\", background: \"linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%)\" }}>\n      <TopBar current=\"knowledge/profile\" />\n      <div style={{ maxWidth: 800, margin: \"0 auto\", padding: 24 }}>\n        <h1 style={{ color: \"white\", fontSize: 22, margin: \"0 0 12px\" }}>\n          {isMe ? \"我的公开资料\" : `对方资料: ${profile?.mainSkill ?? \"—\"}`}\n        </h1>\n        <div style={{ background: \"white\", borderRadius: 12, padding: 24 }}>\n          {profile ? <pre style={{ fontSize: 13, lineHeight: 1.7 }}>{JSON.stringify(profile, null, 2)}</pre> : <div style={{ color: \"#6b7280\" }}>暂无资料</div>}\n          {isMe && (\n            <div style={{ marginTop: 16 }}>\n              <Link href=\"/knowledge\" style={{ color: \"#4f46e5\", fontSize: 14 }}>← 返回知识库</Link>\n            </div>\n          )}\n        </div>\n      </div>\n    </main>\n  );\n}\n```\n\n这一版是简化的过渡页 — 显示从 `/api/auth/me` 或 `/api/opc/[opcId]/profile` 拉的 profile 原始 JSON. 阶段 3 重做美化.\n\n- [ ] **Step 3: 新建 knowledge/[id] 单条目查看 (替代需要时的 /opc/profile 查看页)**\n\n> 阶段 1 只要求把旧路径跳走, 不必现在就做每条知识详情。占位即可。\n\n文件 `app/knowledge/[id]/page.tsx`:\n\n```tsx\n\"use client\";\nimport { useEffect, useState } from \"react\";\nimport { useParams } from \"next/navigation\";\nimport TopBar from \"../../_components/TopBar\";\n\nexport default function KnowledgeEntryDetailPage() {\n  const apiBase = process.env.NEXT_PUBLIC_API_BASE || \"\";\n  const params = useParams<{ id: string }>();\n  const id = params?.id ?? \"\";\n  const [data, setData] = useState<{ id: string; entryDimension: string; entryType: string; rawText: string; updatedAt: string } | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    if (!id) return;\n    const token = localStorage.getItem(\"opphubToken\") || \"\";\n    fetch(`${apiBase}/api/knowledge?opcId=me`, { headers: { authorization: \"Bearer \" + token } })\n      .then((r) => r.json())\n      .then((j) => {\n        const found = (j?.data || []).find((e: any) => e.id === id);\n        setData(found ?? null);\n      })\n      .catch(() => {})\n      .finally(() => setLoading(false));\n  }, [id, apiBase]);\n\n  return (\n    <main style={{ minHeight: \"100vh\", background: \"linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%)\" }}>\n      <TopBar current=\"knowledge\" />\n      <div style={{ maxWidth: 800, margin: \"0 auto\", padding: 24 }}>\n        {loading && <div style={{ background: \"white\", borderRadius: 12, padding: 32, textAlign: \"center\" }}>⏳ 加载中...</div>}\n        {data && (\n          <div style={{ background: \"white\", borderRadius: 12, padding: 24 }}>\n            <div style={{ fontSize: 13, color: \"#9ca3af\", marginBottom: 4 }}>{data.entryType}</div>\n            <h1 style={{ fontSize: 20, color: \"#1f2937\", margin: \"0 0 12px\" }}>{data.entryDimension}</h1>\n            <pre style={{ fontSize: 13, lineHeight: 1.7, whiteSpace: \"pre-wrap\", color: \"#374151\" }}>{data.rawText}</pre>\n          </div>\n        )}\n      </div>\n    </main>\n  );\n}\n```\n\n- [ ] **Step 4: 验证 build**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"(Compiled|error|knowledge|discover|matches)\" | head -10\n```\n\n预期: `Compiled successfully`, 路由里出现 `/knowledge`, `/knowledge/profile`, `/knowledge/[id]`\n\n- [ ] **Step 5: Commit**\n\n```bash\ncd opphub-web\ngit add app/knowledge\ngit commit -m \"feat(knowledge): 新建 /knowledge Tab 页 + 单条目详情 + profile 替代 /opc/profile\"\n```\n\n---\n\n## Task 3: 新建 `/discover` 替代 `/demands` 和 `/marketplace`\n\n**Files:**\n- Create: `app/discover/page.tsx` (整合 demands 表 + marketplace 表)\n\n- [ ] **Step 1: 新建 discover 主页 (Tabs 切买家/卖家视角)**\n\n文件 `app/discover/page.tsx`:\n\n```tsx\n\"use client\";\nimport { useEffect, useState } from \"react\";\nimport { useSearchParams, useRouter } from \"next/navigation\";\nimport TopBar from \"../_components/TopBar\";\n\ntype Demand = {\n  demandId: string;\n  title: string;\n  budget: number;\n  city?: string;\n  deadline?: string;\n  category?: string;\n  status?: string;\n  createdAt: string;\n};\n\ntype OPC = {\n  opcId: string;\n  displayName: string;\n  mainSkill: string;\n  city?: string;\n  trustScore: number;\n  priceMin: string | null;\n  priceMax: string | null;\n};\n\ntype Role = \"buyer\" | \"seller\";\n\nexport default function DiscoverPage() {\n  const apiBase = process.env.NEXT_PUBLIC_API_BASE || \"\";\n  const sp = useSearchParams();\n  const router = useRouter();\n  const initialRole = (sp?.get(\"role\") === \"seller\" ? \"seller\" : \"buyer\") as Role;\n  const [role, setRole] = useState<Role>(initialRole);\n  const [mounted, setMounted] = useState(false);\n  const [loading, setLoading] = useState(false);\n  const [demands, setDemands] = useState<Demand[]>([]);\n  const [opcs, setOpcs] = useState<OPC[]>([]);\n\n  useEffect(() => { setMounted(true); }, []);\n  useEffect(() => {\n    if (!mounted) return;\n    setLoading(true);\n    const token = localStorage.getItem(\"opphubToken\") || \"\";\n    if (role === \"buyer\") {\n      // 我是买方 → 浏览供应方\n      fetch(`${apiBase}/api/opc/marketplace`, { headers: { authorization: \"Bearer \" + token } })\n        .then((r) => r.json()).then((j) => setOpcs(j?.data ?? [])).catch(() => setOpcs([]))\n        .finally(() => setLoading(false));\n    } else {\n      // 我是卖方 → 浏览需求\n      fetch(`${apiBase}/api/demands`, { headers: { authorization: \"Bearer \" + token } })\n        .then((r) => r.json()).then((j) => setDemands(j?.demands ?? j?.data ?? []))\n        .catch(() => setDemands([]))\n        .finally(() => setLoading(false));\n    }\n  }, [mounted, role, apiBase]);\n\n  function switchRole(r: Role) {\n    setRole(r);\n    router.replace(`/discover?role=${r}`);\n  }\n\n  if (!mounted) {\n    return <main style={{ minHeight: \"100vh\", background: \"linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%)\" }}><TopBar current=\"discover\" /><div style={{ color: \"white\", padding: 24 }}>加载中...</div></main>;\n  }\n\n  return (\n    <main style={{ minHeight: \"100vh\", background: \"linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%)\" }}>\n      <TopBar current=\"discover\" />\n      <div style={{ maxWidth: 1200, margin: \"0 auto\", padding: 24 }}>\n        <h1 style={{ color: \"white\", fontSize: 24, margin: \"0 0 6px\" }}>🔍 发现</h1>\n        <p style={{ color: \"rgba(255,255,255,0.85)\", fontSize: 13, margin: \"0 0 16px\" }}>\n          {role === \"buyer\" ? \"看供应方 (你的潜在合作方)\" : \"看需求 (你的潜在客户)\"}\n        </p>\n        <div style={{ display: \"flex\", gap: 4, marginBottom: 12 }}>\n          <button onClick={() => switchRole(\"buyer\")}\n            style={{ padding: \"8px 16px\", borderRadius: 8, border: \"none\", cursor: \"pointer\",\n              background: role === \"buyer\" ? \"white\" : \"rgba(255,255,255,0.2)\",\n              color: role === \"buyer\" ? \"#4f46e5\" : \"white\", fontWeight: role === \"buyer\" ? 600 : 400 }}>\n            我在找 (浏览供应方)\n          </button>\n          <button onClick={() => switchRole(\"seller\")}\n            style={{ padding: \"8px 16px\", borderRadius: 8, border: \"none\", cursor: \"pointer\",\n              background: role === \"seller\" ? \"white\" : \"rgba(255,255,255,0.2)\",\n              color: role === \"seller\" ? \"#4f46e5\" : \"white\", fontWeight: role === \"seller\" ? 600 : 400 }}>\n            我能提供 (浏览需求)\n          </button>\n        </div>\n\n        {loading && <div style={{ background: \"white\", borderRadius: 12, padding: 32, textAlign: \"center\", color: \"#6b7280\" }}>⏳ 加载中...</div>}\n\n        {!loading && role === \"buyer\" && (\n          <div style={{ background: \"white\", borderRadius: 12, padding: 16 }}>\n            <div style={{ fontSize: 14, color: \"#6b7280\", marginBottom: 12 }}>共 {opcs.length} 个供应方</div>\n            {opcs.length === 0 ? (\n              <div style={{ color: \"#9ca3af\", textAlign: \"center\", padding: 32 }}>暂无供应方</div>\n            ) : (\n              <div style={{ display: \"grid\", gap: 10, gridTemplateColumns: \"repeat(auto-fill, minmax(280px, 1fr))\" }}>\n                {opcs.map((o) => (\n                  <a key={o.opcId} href={`/knowledge/profile?opcId=${o.opcId}`} style={{ textDecoration: \"none\" }}>\n                    <div style={{ border: \"1px solid #e5e7eb\", borderRadius: 8, padding: 12 }}>\n                      <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\" }}>{o.displayName}</div>\n                      <div style={{ fontSize: 13, color: \"#4f46e5\", margin: \"4px 0\" }}>{o.mainSkill}</div>\n                      <div style={{ fontSize: 12, color: \"#6b7280\" }}>\n                        {o.city ?? \"—\"} · ⭐{o.trustScore} · {o.priceMin ?? \"—\"}~{o.priceMax ?? \"—\"}\n                      </div>\n                    </div>\n                  </a>\n                ))}\n              </div>\n            )}\n          </div>\n        )}\n\n        {!loading && role === \"seller\" && (\n          <div style={{ background: \"white\", borderRadius: 12, padding: 16 }}>\n            <div style={{ fontSize: 14, color: \"#6b7280\", marginBottom: 12 }}>共 {demands.length} 条需求</div>\n            {demands.length === 0 ? (\n              <div style={{ color: \"#9ca3af\", textAlign: \"center\", padding: 32 }}>暂无公开需求</div>\n            ) : (\n              <div style={{ display: \"grid\", gap: 10, gridTemplateColumns: \"repeat(auto-fill, minmax(280px, 1fr))\" }}>\n                {demands.map((d) => (\n                  <a key={d.demandId} href={`/demands/${d.demandId}`} style={{ textDecoration: \"none\" }}>\n                    <div style={{ border: \"1px solid #e5e7eb\", borderRadius: 8, padding: 12 }}>\n                      <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\" }}>{d.title}</div>\n                      <div style={{ fontSize: 12, color: \"#6b7280\", marginTop: 4 }}>\n                        ¥{d.budget} · {d.city ?? \"—\"}\n                      </div>\n                    </div>\n                  </a>\n                ))}\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n    </main>\n  );\n}\n```\n\n- [ ] **Step 2: Build 验证**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"(Compiled|error|discover)\" | head -10\n```\n\n预期: `/discover` 出现在路由列表, 无 error\n\n- [ ] **Step 3: Commit**\n\n```bash\ncd opphub-web\ngit add app/discover\ngit commit -m \"feat(discover): 新建 /discover (合并 /demands + /marketplace 视角)\"\n```\n\n---\n\n## Task 4: 新建 `/matches` 替代 `/match`\n\n**Files:**\n- Create: `app/matches/page.tsx` (暂时代理 `/match` 内容, 但 URL 改成复数)\n\n- [ ] **Step 1: 复制 match/page.tsx 到 matches/page.tsx, 改 current=**\n\n```bash\ncd opphub-web\nsed 's|\"match\"|\"matches\"|g; s|/api/opc/match/today|/api/opc/match/today|g' app/match/page.tsx > app/matches/page.tsx\ngrep -n \"match\\|Match\" app/matches/page.tsx | head -10\n```\n\n预期: 看到 `/api/opc/match/today` 仍保留 (server 路径未改), `current=\"matches\"` 已替换\n\n- [ ] **Step 2: 在 matches/page.tsx 顶部注释里更新 spec 引用**\n\n文件 `app/matches/page.tsx`, 改注释行 (从原 file 的注释基础替换):\n\n```tsx\n// /matches · 撮合流 (v3.4 spec 阶段 1: 替代 /match, 旧路径 301 到此)\n// 当前阶段先复刻 /match 内容, 阶段 3 重做撮合详情/状态机\n// 数据源: GET /api/opc/match/today\n// 字段: matchingId/demandId/demandTitle/demandBudget/demandCity/demandDeadline/opc/scores/explainText/createdAt\n```\n\n(替换文件最顶部的注释段, 只改 comment 不动 logic)\n\n- [ ] **Step 3: Build 验证**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"(Compiled|error|/matches|/match)\" | head -10\n```\n\n预期: `/matches` 出现在路由列表, `/match` 仍然存在 (因为 redirect 在 server 级, 路由仍编译), 无 error\n\n- [ ] **Step 4: Commit**\n\n```bash\ncd opphub-web\ngit add app/matches\ngit commit -m \"feat(matches): 新建 /matches (复刻 /match 内容, 阶段 3 重做)\"\n```\n\n---\n\n## Task 5: 改 TopBar 用 6 个一级入口\n\n**Files:**\n- Modify: `app/_components/TopBar.tsx` (`PAGE_NAME` map + `desktopLinks` + `mobileLinks` 三处)\n\n- [ ] **Step 1: 改 PAGE_NAME 加新键值**\n\n文件 `app/_components/TopBar.tsx`, 修改 PAGE_NAME 常量 (在第 19 行附近):\n\n```tsx\nconst PAGE_NAME: Record<string, string> = {\n  \"\": \"首页\",\n  dashboard: \"工作台\",\n  matches: \"撮合\",\n  orders: \"交易\",\n  knowledge: \"知识库\",\n  \"knowledge/profile\": \"我的资料\",\n  discover: \"发现\",\n  wallet: \"钱包\",\n  messages: \"通知\",\n  \"messages/legacy\": \"消息\",\n  \"account/security\": \"账号安全\",\n  admin: \"后台\",\n  onboarding: \"引导\",\n  login: \"登录\",\n  register: \"注册\",\n  activate: \"设备激活\",\n};\n```\n\n- [ ] **Step 2: 改 desktopLinks (替换为 6 个)**\n\n文件 `app/_components/TopBar.tsx`, 替换 `desktopLinks` 整个 JSX 块:\n\n```tsx\nconst desktopLinks = (\n  <>\n    <Link href=\"/dashboard\" className={linkCls}>工作台</Link>\n    <Link href=\"/knowledge\" className={linkCls}>📇 知识库</Link>\n    <Link href=\"/discover\" className={linkCls}>🔍 发现</Link>\n    <Link href=\"/matches\" className={linkCls}>🤝 撮合</Link>\n    <Link href=\"/orders\" className={linkCls}>📦 交易</Link>\n    <Link href=\"/wallet\" className={linkCls}>💰 钱包</Link>\n    {me?.isAdmin && (\n      <Link href=\"/admin/dashboard\" className={linkCls}>🛡️ 后台</Link>\n    )}\n    <a href=\"#\" onClick={(e) => { e.preventDefault(); logout(); }} className={linkCls}>退出</a>\n  </>\n);\n```\n\n- [ ] **Step 3: 改 mobileLinks 同步**\n\n文件 `app/_components/TopBar.tsx`, 替换 `mobileLinks` JSX 块:\n\n```tsx\nconst mobileLinks = (\n  <>\n    <Link href=\"/dashboard\" className=\"block px-4 py-3 text-gray-800 hover:bg-gray-100 border-b border-gray-100\">工作台</Link>\n    <Link href=\"/knowledge\" className=\"block px-4 py-3 text-gray-800 hover:bg-gray-100 border-b border-gray-100\">📇 知识库</Link>\n    <Link href=\"/discover\" className=\"block px-4 py-3 text-gray-800 hover:bg-gray-100 border-b border-gray-100\">🔍 发现</Link>\n    <Link href=\"/matches\" className=\"block px-4 py-3 text-gray-800 hover:bg-gray-100 border-b border-gray-100\">🤝 撮合</Link>\n    <Link href=\"/orders\" className=\"block px-4 py-3 text-gray-800 hover:bg-gray-100 border-b border-gray-100\">📦 交易</Link>\n    <Link href=\"/wallet\" className=\"block px-4 py-3 text-gray-800 hover:bg-gray-100 border-b border-gray-100\">💰 钱包</Link>\n    {me?.isAdmin && (\n      <Link href=\"/admin/dashboard\" className=\"block px-4 py-3 text-primary-600 font-semibold hover:bg-primary-50 border-b border-gray-100\">🛡️ 后台</Link>\n    )}\n    <a\n      href=\"#\"\n      onClick={(e) => { e.preventDefault(); logout(); }}\n      className=\"block px-4 py-3 text-red-600 hover:bg-red-50\"\n    >\n      退出\n    </a>\n  </>\n);\n```\n\n- [ ] **Step 4: 新建 /wallet 占位页 (TopBar 链接不空)**\n\n文件 `app/wallet/page.tsx`:\n\n```tsx\n\"use client\";\nimport { useEffect, useState } from \"react\";\nimport TopBar from \"../_components/TopBar\";\n\nexport default function WalletPage() {\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => { setMounted(true); }, []);\n  if (!mounted) return <main style={{ minHeight: \"100vh\", background: \"linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%)\" }}><TopBar current=\"wallet\" /><div style={{ color: \"white\", padding: 24 }}>加载中...</div></main>;\n  return (\n    <main style={{ minHeight: \"100vh\", background: \"linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%)\" }}>\n      <TopBar current=\"wallet\" />\n      <div style={{ maxWidth: 800, margin: \"0 auto\", padding: 24 }}>\n        <h1 style={{ color: \"white\", fontSize: 24 }}>💰 钱包</h1>\n        <p style={{ color: \"rgba(255,255,255,0.85)\", fontSize: 13, marginBottom: 16 }}>收入/支出/提现 (阶段 7 实装, 当前占位)</p>\n        <div style={{ background: \"white\", borderRadius: 12, padding: 48, textAlign: \"center\" }}>\n          <div style={{ fontSize: 48, marginBottom: 12 }}>💰</div>\n          <div style={{ color: \"#6b7280\" }}>暂无流水</div>\n        </div>\n      </div>\n    </main>\n  );\n}\n```\n\n- [ ] **Step 5: Build 验证**\n\n```bash\ncd opphub-web && npm run build 2>&1 | tail -30\n```\n\n预期: 路由包括 `/knowledge`, `/knowledge/profile`, `/knowledge/[id]`, `/discover`, `/matches`, `/wallet`, 加上所有旧的 `/match`, `/demands`, `/marketplace`, `/me/capabilities`, `/opc/profile` 仍编译 (用于 redirect 命中)\n\n- [ ] **Step 6: Commit**\n\n```bash\ncd opphub-web\ngit add app/_components/TopBar.tsx app/wallet\ngit commit -m \"feat(nav): TopBar 改 6 个一级 + 加 /wallet 占位 (阶段 1)\"\n```\n\n---\n\n## Task 6: 浏览器手测 + 全路径 301 校验\n\n**Files:**\n- (本地浏览器, 不写代码)\n\n- [ ] **Step 1: 本地启动 dev**\n\n```bash\ncd opphub-web && npm run build && (npm start &) && sleep 8 && echo \"started\"\n```\n\n预期: 服务起在 :3000\n\n- [ ] **Step 2: 试访问旧路径**\n\n```bash\nfor p in /me/capabilities /opc/profile /marketplace /demands /match; do\n  echo \"=== $p ===\"\n  curl -s -o /dev/null -w \"%{http_code} %{redirect_url}\\n\" \"http://localhost:3000$p\"\ndone\n```\n\n预期: 全部 `301` 加新 location (`/knowledge` `/knowledge/profile` `/discover?role=seller` `/discover?role=buyer` `/matches`)\n\n- [ ] **Step 3: 试新路径编译能 GET**\n\n```bash\nfor p in /knowledge /discover /matches /wallet /knowledge/profile /knowledge/foo; do\n  echo \"=== $p ===\"\n  curl -s -o /dev/null -w \"%{http_code}\\n\" \"http://localhost:3000$p\"\ndone\n```\n\n预期: 全部 `200` (Next.js 即使 id 不存在也是 200, 内容里显示 fallback)\n\n- [ ] **Step 4: 视觉校验 (人工浏览器)**\n\n打开浏览器, 在 `/knowledge` 看 Tab 切换 + EmptyState 渲染 OK\n\n## Task 7: 部署 ECS + 全站验证\n\n**Files:**\n- 同步到 ECS: `next.config.js`, `app/knowledge/*`, `app/discover/*`, `app/matches/*`, `app/_components/TopBar.tsx`, `app/wallet/*`\n\n- [ ] **Step 1: scp 文件到 ECS**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev\nscp opphub-web/next.config.js opphub-ecs:/opt/1panel/www/opphub-web/next.config.js\nscp -r opphub-web/app/knowledge opphub-ecs:/opt/1panel/www/opphub-web/app/\nscp -r opphub-web/app/discover opphub-ecs:/opt/1panel/www/opphub-web/app/\nscp -r opphub-web/app/matches opphub-ecs:/opt/1panel/www/opphub-web/app/\nscp -r opphub-web/app/wallet opphub-ecs:/opt/1panel/www/opphub-web/app/\nscp opphub-web/app/_components/TopBar.tsx opphub-ecs:/opt/1panel/www/opphub-web/app/_components/TopBar.tsx\n```\n\n- [ ] **Step 2: 容器内 build + 重启**\n\n```bash\nssh opphub-ecs \"docker exec opphub-web sh -c 'cd /app && npm run build 2>&1 | tail -5 && docker restart opphub-web'\"\n```\n\n注: 第二段 `docker restart` 实际是宿主命令, 不是容器内, 需要拆开:\n\n```bash\nssh opphub-ecs \"docker exec opphub-web sh -c 'cd /app && npm run build 2>&1 | tail -5'\"\nssh opphub-ecs \"docker restart opphub-web\"\n```\n\n- [ ] **Step 3: 验证 ECS 跳转**\n\n```bash\nssh opphub-ecs 'for p in /me/capabilities /opc/profile /marketplace /demands /match; do\n  code=$(curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost:3000$p\")\n  loc=$(curl -s -o /dev/null -w \"%{redirect_url}\" \"http://localhost:3000$p\")\n  echo \"$p -> $code $loc\"\ndone'\n```\n\n预期: 每个输出 `301` 后是新路径\n\n- [ ] **Step 4: 浏览器访问生产 URL `https://opphub.aiforce.cloud` 检查 TopBar**\n\n人工: 看顶导航 6 个, 点各 tab, 旧路径进入后跳新路径\n\n- [ ] **Step 5: Commit (若是单独的 \"deploy\" commit)**\n\n```bash\ncd opphub-web\ngit tag phase-1-nav-r2c\ngit log --oneline -5\n```\n\n(无需 commit, tag 也行, 主要给个里程碑标记)\n\n---\n\n## 完成判定\n\n- [ ] Task 1: redirects 加好, 旧路径 301 → 新路径 (curl 校验)\n- [ ] Task 2: `/knowledge` Tab 化 4 种角色, EmptyState 显示中文\n- [ ] Task 3: `/discover` 双视角切换 OK\n- [ ] Task 4: `/matches` 复刻 `/match` 内容\n- [ ] Task 5: TopBar 6 个入口, 新 `/wallet` 占位\n- [ ] Task 6: 本地构建 + curl + 浏览器手测通过\n- [ ] Task 7: ECS build + 容器重启, 旧路径 301 在生产环境也对\n\n最终产品现象: 用户打开 opphub.aiforce.cloud/dashboard → 顶部看到 6 个一级: 工作台 / 📇知识库 / 🔍发现 / 🤝撮合 / 📦交易 / 💰钱包; 点老链接自动跳\n\n## 后续阶段 (不在此 plan 范围)\n\n- 阶段 2: 知识库语义 + entryDimension 枚举 + 录入三选一\n- 阶段 3: 撮合流转 (matchings/inquiries/bids 表 + `/matches/[id]` + 手动撮合池)\n- 阶段 4: 订单状态机 + 支付 (orders 表 + `/orders/[id]` + 状态按钮)\n- 阶段 5: 推送 + Onboarding (opphub-ws 对接 + onboarding 4 屏 + 录入正反馈)\n- 阶段 6: 错误兜底 + 5 状态规范\n- 阶段 7: 钱包 + 设置 (含提现/充值实际流程)\n\n每个阶段单独走 spec → plan → implement 循环。\n\nFile v5.0.0:docs/superpowers/plans/2026-07-23-phase2-confirm-and-group.md\n\n# OppHub 阶段 2 修订版 v4：录入确认 + 解析字段 + 知识库分组展示\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** skill 录入走\"用户确认\"流程(覆盖 4 种 entryType), 同时输出结构化字段; web `/knowledge` 按 opcId 分组, 每组只显示现行版.\n\n**Architecture:** skill 端新增 confirmation state machine (输出 confirm 卡片 → 收 yes/no/modify → submit), cards 跟 parsedFields 一起入库. web 端 `/knowledge` 用 opcId 分组, 现行版 (supersededAt IS NULL) 渲染, 历史版只能从\"📜 历史\"按钮看. web 端顶部加录入引导.\n\n**Tech Stack:** openclaw skill (录入主路), opphub-web (展示 + 引导)\n\n---\n\n## 0. 关键边界 (钉死)\n\n- **录入主路**: openclaw skill, IM 端\n- **web 不写**: 不建表单\n- **匹配 / 推送**: 不动, 那是 server worker + opphub-ws 的事\n- **enum 强制**: 不做\n- **rawText 强结构校验**: 不做, 但加版本头\n\n---\n\n## 1. 任务地图 (6 个)\n\n```\nTask 1: skill rawText 顶部加 <!-- opphub-raw-text-v1 --> 头 (3 个 bin)\nTask 2: skill 端 card 同时输出 parsedFields 结构化字段\nTask 3: skill 端 confirm 状态机 - 4 种 entryType 全部列清单, 用户确认后入库\nTask 4: web /knowledge 按 opcId 分组, 每组只显示现行版\nTask 5: web /knowledge 顶部\"如何录入\"文字引导\nTask 6: 部署 ECS + 终验\n```\n\n---\n\n## Task 1: skill 端 rawText 加 `<!-- opphub-raw-text-v1 -->` 头\n\n**Files:**\n- Modify: `opphub-skill/bin/opphub-knowledge-card.js`\n- Modify: `opphub-skill/bin/opphub-knowledge-discover.js`\n- Modify: `opphub-skill/bin/opphub-knowledge-submit.js`\n\n- [ ] **Step 1: knowledge-discover.js passthrough 时加版本头**\n\n文件 `opphub-skill/bin/opphub-knowledge-discover.js`, 找到:\n\n```js\n      rawText: args.rawText,\n```\n\n修改为:\n\n```js\n      rawText: ensureVersionHeader(args.rawText),\n```\n\n并在文件顶部 (import 之后) 添加工具函数:\n\n```js\n// v4.0.9: rawText 顶部加版本头, 让 server 端能识别协议版本\n//   v1 (当前): 自由文本, 不锁结构, 内容是 LLM 自由拼出来的 rawText\n//   v2 (未来): 加结构化字段 (parseKeyFields 强校验)\nfunction ensureVersionHeader(rawText) {\n  if (!rawText) return rawText;\n  if (rawText.startsWith(\"<!-- opphub-raw-text-v1 -->\")) return rawText;\n  return `<!-- opphub-raw-text-v1 -->\\n${rawText}`;\n}\n```\n\n- [ ] **Step 2: knowledge-discover.js skeleton 也加版本头**\n\n找到 `buildRawTextSkeleton` 函数, 修改 return:\n\n```js\nfunction buildRawTextSkeleton(name) {\n  const skeleton = `# ${name} · 自动画像 (skill turn 阶段 1 拼骨架)\\n\\n` +\n    SOURCE_SKELETON.map((s, i) => `## ${i + 1}. ${s.name}\\n(${s.purpose})\\n\\n`).join(\"\");\n  return `<!-- opphub-raw-text-v1 -->\\n${skeleton}`;\n}\n```\n\n- [ ] **Step 3: knowledge-card.js card.text 加版本头**\n\n文件 `opphub-skill/bin/opphub-knowledge-card.js`, 找到所有 `text: \\`${name} · ...` 字段 (ability / upstream / downstream 三段), 在前面加 `<!-- opphub-raw-text-v1 -->\\n`:\n\n(改 3 处, 示意 ability 一处)\n\n```js\n// 改前:\ntext: `${name} · 能力卡片 · ${dimension}\\n\\n(证据: rawText 包含 \"${dimension}\")\\n\\n(来自 rawText 实查, 非模板填空)`,\n\n// 改后:\ntext: `<!-- opphub-raw-text-v1 -->\\n${name} · 能力卡片 · ${dimension}\\n\\n(证据: rawText 包含 \"${dimension}\")\\n\\n(来自 rawText 实查, 非模板填空)`,\n```\n\n- [ ] **Step 4: knowledge-submit.js 兜底**\n\n文件 `opphub-skill/bin/opphub-knowledge-submit.js`, 在 `for (const card of cards)` 块内, 找到:\n\n```js\n    const { type, dimension, text } = card;\n    if (!type || !dimension || !text) {\n      continue;\n    }\n    const idempotencyKey = sha256(`${opcId}|${type}|${dimension}`);\n    const fullRawText = rawTextCtx ? `${rawTextCtx}\\n\\n---\\n${text}` : text;\n    const contentHash = sha256(fullRawText);\n```\n\n修改为:\n\n```js\n    const { type, dimension, text } = card;\n    if (!type || !dimension || !text) {\n      continue;\n    }\n    // v4.0.9: 兜底 - 老的 card.text 没版本头时, 补上 (旧 skill 数据兼容)\n    const textWithHeader = text.startsWith(\"<!-- opphub-raw-text-v1 -->\")\n      ? text\n      : `<!-- opphub-raw-text-v1 -->\\n${text}`;\n    const idempotencyKey = sha256(`${opcId}|${type}|${dimension}`);\n    const fullRawText = rawTextCtx ? `${rawTextCtx}\\n\\n---\\n${textWithHeader}` : textWithHeader;\n    const contentHash = sha256(fullRawText);\n```\n\n- [ ] **Step 5: 验证 skill 输出带版本头**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/skills/opphub\n\necho \"=== discover passthrough 期望 rawText 首行是 v1 ===\"\nnode bin/opphub-knowledge-discover --name \"测试公司\" --raw-text \"上海睿驰嘉禾是一家 MCN\" --json 2>&1 | python3 -c \"\nimport sys, json\nd = json.load(sys.stdin)\nprint('rawText 首行:', d.get('rawText','').split(chr(10))[0])\n\"\n\necho \"=== discover query-plan skeleton 也带 v1 ===\"\nnode bin/opphub-knowledge-discover --name \"测试公司\" --json 2>&1 | python3 -c \"\nimport sys, json\nd = json.load(sys.stdin)\nprint('skeleton 首行:', d.get('rawTextSkeleton','').split(chr(10))[0])\n\"\n```\n\n预期: 两个输出首行都是 `<!-- opphub-raw-text-v1 -->`\n\n- [ ] **Step 6: Commit**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/skills/opphub\ngit checkout main\ngit add bin/opphub-knowledge-card.js bin/opphub-knowledge-discover.js bin/opphub-knowledge-submit.js\ngit commit -m \"feat(skill): rawText 顶部加 opphub-raw-text-v1 版本头 (未来 v2 兼容)\"\n```\n\n---\n\n## Task 2: skill 端 card 同时输出 parsedFields 结构化字段\n\n**Files:**\n- Modify: `opphub-skill/bin/opphub-knowledge-card.js`\n- Modify: `opphub-skill/bin/opphub-knowledge-submit.js`\n\n**背景**: server schema 已经有 `parsedFields: Json` 字段 (prisma/schema.prisma:172 注释里写 \"拆出的结构化字段\"), 但 skill 端目前不输出. 这一 task 让 skill 把抽出的结构化字段带过去.\n\n- [ ] **Step 1: knowledge-card.js 顶层输出加 parsedFields 字段**\n\n文件 `opphub-skill/bin/opphub-knowledge-card.js`, 找到 main 函数最后 return 的 JSON (大约 `result` 对象), 在 `cards` 字段后加 `parsedFields`:\n\n```js\n// 改前 (示意):\nconst result = {\n  ok: true,\n  name,\n  industry: { ... },\n  cards,\n  unmatchedTemplates,\n  ...\n};\n\n// 改后:\nconst result = {\n  ok: true,\n  name,\n  industry: { ... },\n  cards,\n  parsedFields: extractParsedFields(name, rawText, industry),  // 新增\n  unmatchedTemplates,\n  ...\n};\n```\n\n- [ ] **Step 2: extractParsedFields 函数实现**\n\n在 knowledge-card.js 顶部 (INDUSTRY_TEMPLATES 后面) 加:\n\n```js\n// 从 rawText 抽结构化字段 (公司基础信息)\nfunction extractParsedFields(name, rawText, industry) {\n  const fields = {};\n  fields.companyName = name;\n  fields.industry = industry ? { code: industry.code, name: industry.name } : null;\n\n  // 法律实体\n  const legalPersonMatch = rawText.match(/法人[:：]\\s*([^\\n]+)/);\n  if (legalPersonMatch) fields.legalPerson = legalPersonMatch[1].trim().slice(0, 50);\n\n  // 注册资本\n  const capitalMatch = rawText.match(/注册资本[:：]\\s*([^\\n]+)/);\n  if (capitalMatch) fields.registeredCapital = capitalMatch[1].trim().slice(0, 50);\n\n  // 信用代码\n  const creditMatch = rawText.match(/(?:信用代码|统一社会信用代码)[:：]\\s*([A-Z0-9]{18,20})/);\n  if (creditMatch) fields.creditCode = creditMatch[1].trim();\n\n  // 团队规模\n  const sizeMatch = rawText.match(/(?:团队规模|规模|人数)[:：]\\s*([^\\n]+)/);\n  if (sizeMatch) fields.teamSize = sizeMatch[1].trim().slice(0, 50);\n\n  // 地址\n  const addressMatch = rawText.match(/地址[:：]\\s*([^\\n]+)/);\n  if (addressMatch) fields.address = addressMatch[1].trim().slice(0, 100);\n\n  // 城市 (从地址或 rawText 提取)\n  const cities = [\"上海\", \"北京\", \"深圳\", \"广州\", \"杭州\", \"成都\", \"南京\", \"武汉\", \"苏州\", \"天津\", \"重庆\"];\n  for (const c of cities) {\n    if (rawText.includes(c)) { fields.city = c; break; }\n  }\n\n  // 业务描述 (取 2. 业务描述 段)\n  const bizMatch = rawText.match(/##\\s*2\\.\\s*业务描述\\s*\\n+([\\s\\S]*?)(?=\\n##\\s|\\s*$)/);\n  if (bizMatch) fields.businessDescription = bizMatch[1].trim().slice(0, 500);\n\n  return fields;\n}\n```\n\n- [ ] **Step 3: knowledge-submit.js 把 parsedFields 传给 ingest**\n\n文件 `opphub-skill/bin/opphub-knowledge-submit.js`, 找到 cards 加载的地方 (大约 `const cards = ...`), 在读 cards.json 后, 提取 parsedFields:\n\n```js\n// 改前:\nlet cardsDoc;\ntry {\n  cardsDoc = JSON.parse(readFileSync(args.cards, \"utf8\"));\n} catch (e) { ... }\n\nconst name = cardsDoc.name || args.company || \"(未指定)\";\nconst cards = Array.isArray(cardsDoc.cards) ? cardsDoc.cards : ...;\n```\n\n修改为:\n\n```js\nlet cardsDoc;\ntry {\n  cardsDoc = JSON.parse(readFileSync(args.cards, \"utf8\"));\n} catch (e) { ... }\n\nconst name = cardsDoc.name || args.company || \"(未指定)\";\nconst cards = Array.isArray(cardsDoc.cards) ? cardsDoc.cards : ...;\nconst parsedFields = cardsDoc.parsedFields || null;  // 新增\n```\n\n然后在 for 循环的 body 提交时, 加 `parsedFields` 字段. 找到 `body: JSON.stringify({` 那块:\n\n```js\n        body: JSON.stringify({\n          opcId,\n          rawText: fullRawText,\n          sourceType: \"auto\",\n          entryType: type,\n          entryDimension: dimension,\n          idempotencyKey,\n          contentHash,\n          forceOverride: args.forceOverrideConflict,\n        }),\n```\n\n修改为 (用 const card 关联 parsedFields 一次, 因为 cards 数组里其实没 parsedFields, 是顶层):\n\n```js\n        body: JSON.stringify({\n          opcId,\n          rawText: fullRawText,\n          sourceType: \"auto\",\n          entryType: type,\n          entryDimension: dimension,\n          idempotencyKey,\n          contentHash,\n          parsedFields: parsedFields ?? undefined,  // 第一张卡带, 后续重复 server 用 no_change\n          forceOverride: args.forceOverrideConflict,\n        }),\n```\n\n(只在第一张卡带 parsedFields 也可以, server 端用 idempotencyKey 关联, 后续 no_change 时不需要再传. 但为简化, 每张都传, server 会去重.)\n\n- [ ] **Step 4: 验证 card 输出含 parsedFields**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/skills/opphub\n\necho \"=== knowledge-card 输出含 parsedFields ===\"\nnode bin/opphub-knowledge-card \\\n  --name \"测试公司\" \\\n  --raw-text '# 测试公司 · 自动画像\n\n## 1. 工商信息\n- 名称: 测试公司\n- 法人: 张三\n- 注册资本: 100万\n- 信用代码: 91310110MA12345678\n- 团队规模: 50人\n- 地址: 上海市杨浦区某路 1 号\n\n## 2. 业务描述\n短视频内容制作, 达人投放\n\n' \\\n  --json 2>&1 | python3 -c \"\nimport sys, json\nd = json.load(sys.stdin)\nprint('parsedFields:', json.dumps(d.get('parsedFields', {}), ensure_ascii=False, indent=2))\n\"\n```\n\n预期: 输出 parsedFields 含 companyName / legalPerson / city / businessDescription 等\n\n- [ ] **Step 5: Commit**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/skills/opphub\ngit checkout main\ngit add bin/opphub-knowledge-card.js bin/opphub-knowledge-submit.js\ngit commit -m \"feat(skill): knowledge-card 输出 parsedFields, knowledge-submit 透传\"\n```\n\n---\n\n## Task 3: skill 端 confirmation state machine (4 种 entryType 全部列清单)\n\n**Files:**\n- Modify: `opphub-skill/bin/opphub-knowledge-card.js` (加 confirmation payload)\n- Modify: `opphub-knowledge-submit.js` 加 `--confirm` 开关 (默认拒绝, 收到才入)\n- Modify: `SKILL.md` (更新「偶合录入 [公司]」的描述, 加确认步骤)\n\n**背景**: 当前 skill 6 步里第 4 步说 \"给你 1 张清单确认\" 但没具体实现. 这一 task 让 card 步骤输出 confirm 卡片 (含 4 种 entryType 全部), submit 默认拒绝, 收到 `--confirm` 才入库.\n\n注意: 这是 skill 端\"工作流\"调整, 不是新增 bin.\n\n- [ ] **Step 1: knowledge-card.js 输出确认 payload**\n\n文件 `opphub-skill/bin/opphub-knowledge-card.js`, 找到 main 函数最后 return 的 JSON, 在 `unmatchedTemplates` 后加 `confirmation` 字段:\n\n```js\nconst result = {\n  ok: true,\n  name,\n  industry: { ... },\n  cards,\n  parsedFields,\n  unmatchedTemplates,\n  confirmation: buildConfirmationList(name, industry, cards, parsedFields),  // 新增\n  ...\n};\n```\n\n- [ ] **Step 2: buildConfirmationList 函数**\n\n在 knowledge-card.js 顶部 `extractParsedFields` 后面加:\n\n```js\n// 生成 4 种 entryType 全部的确认清单\n// 用途: 供 IM bot 给用户发「将要录入什么」的清单\nfunction buildConfirmationList(name, industry, cards, parsedFields) {\n  const groups = {\n    ability: { label: \"我能提供\", emoji: \"✅\", items: [] },\n    downstream: { label: \"我想找\", emoji: \"🔍\", items: [] },\n    upstream: { label: \"我的依赖\", emoji: \"⬆️\", items: [] },\n    peer: { label: \"同行关系\", emoji: \"🔗\", items: [] },\n  };\n  for (const c of cards) {\n    if (groups[c.type]) {\n      groups[c.type].items.push({\n        dimension: c.dimension,\n        evidenceSource: c.evidenceSource ?? \"rawText 关键词命中\",\n      });\n    }\n  }\n\n  // 把空组去掉, 只列有内容的\n  const nonEmpty = Object.entries(groups).filter(([_, g]) => g.items.length > 0);\n\n  return {\n    name,\n    industry: industry ? { code: industry.code, name: industry.name } : null,\n    companyName: parsedFields?.companyName ?? name,\n    businessDescription: parsedFields?.businessDescription ?? null,\n    totalCards: cards.length,\n    groups: nonEmpty.map(([type, g]) => ({ type, label: g.label, emoji: g.emoji, items: g.items })),\n    instructions: \"回复「确认」入库；回复「删 <type.dimension>」去掉某条；回复「重抽」回到阶段 1\",\n  };\n}\n```\n\n- [ ] **Step 3: knowledge-submit.js 加 `--confirm` 开关**\n\n文件 `opphub-skill/bin/opphub-knowledge-submit.js`, 找到 `parseArgs` 函数, 加 `--confirm` 解析:\n\n```js\nelse if (a === \"--confirm\") args.confirm = true;\n```\n\n并在 main 函数顶部, 在读完 cards 之后, 加校验:\n\n```js\nif (!args.confirm) {\n  if (wantJson) out({\n    ok: false,\n    error: \"needs_confirmation\",\n    message: \"未确认 — 需先跑 knowledge-card 看 confirmation 清单, 用户回复'确认'后再加 --confirm 入库\",\n  });\n  process.exit(1);\n}\n```\n\n(没 --confirm 直接拒绝, 防止 skill 端自动入库跳过用户确认)\n\n- [ ] **Step 4: SKILL.md 更新「偶合录入」流程**\n\n文件 `opphub-skill/SKILL.md`, 找到 \"### 5. @bot 说\\\"偶合录入 [公司名]\\\"\" 这一节, 在最后追加确认步骤:\n\n在原 `6 步` 描述后加:\n\n> 第 4 步拆出能力后, bot **不直接入库**, 先给一张确认清单 (含 4 种 entryType: 我能提供/我想找/我的依赖/同行关系).\n> 用户回复:\n> - 「确认」 → bot 调 `knowledge-submit --confirm` 入库\n> - 「删 <type.dimension>」 → bot 去掉某条重发清单\n> - 「重抽」 → 回到阶段 1 重新联网查\n> - 「改 <字段>」 → 改解析字段后重发清单\n\n- [ ] **Step 5: 验证 confirmation 输出**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/skills/opphub\n\necho \"=== knowledge-card 输出含 confirmation ===\"\nnode bin/opphub-knowledge-card \\\n  --name \"测试公司\" \\\n  --raw-text '# 测试公司\n\n## 2. 业务描述\n达人投放, 短视频脚本\n\n' \\\n  --json 2>&1 | python3 -c \"\nimport sys, json\nd = json.load(sys.stdin)\nprint('confirmation:', json.dumps(d.get('confirmation', {}), ensure_ascii=False, indent=2))\n\"\n\necho \"=== submit 不带 --confirm 期望拒绝 ===\"\necho '[]' > /tmp/empty.json\nnode bin/opphub-knowledge-submit --cards /tmp/empty.json --json 2>&1 | head -10\n```\n\n预期: confirmation 含 4 个 groups (空组会跳过), submit 返 `needs_confirmation` 错误\n\n- [ ] **Step 6: Commit**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/skills/opphub\ngit checkout main\ngit add bin/opphub-knowledge-card.js bin/opphub-knowledge-submit.js SKILL.md\ngit commit -m \"feat(skill): card 输出 confirmation 清单 (4 entryType), submit 加 --confirm 开关\"\n```\n\n---\n\n## Task 4: web /knowledge 按 opcId 分组, 每组只显示现行版\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: 改 fetch + 状态**\n\n文件 `opphub-web/app/knowledge/page.tsx`, 找到 fetch 数据逻辑 (大约 `fetch(\\`${apiBase}/api/knowledge?opcId=me&entryType=${tab}\\`)`). 修改成不带 entryType 过滤 (拉所有现行版, server 默认就只返 `supersededAt: null`):\n\n```tsx\nfetch(`${apiBase}/api/knowledge?opcId=me`, {\n  headers: { authorization: \"Bearer \" + token },\n})\n```\n\n(原 code 带 `&entryType=${tab}` 过滤, 改为拉所有, 然后在客户端按 tab 过滤显示. 因为 grouping 要按 opcId 看全部)\n\n- [ ] **Step 2: 客户端按 entryType 过滤 + 按 opcId 分组**\n\n在 component 里, fetch 数据后:\n\n```tsx\nconst filtered = entries.filter((e) => e.entryType === tab);\nconst grouped: Array<{ opcId: string; entries: typeof entries }> = [];\nconst byOpc = new Map<string, typeof entries>();\nfor (const e of filtered) {\n  const k = parsedCompanyName(e) || \"未命名公司\";  // 同一 opcId 下用 companyName 分组\n  if (!byOpc.has(k)) byOpc.set(k, []);\n  byOpc.get(k)!.push(e);\n}\nfor (const [k, v] of byOpc) grouped.push({ opcId: k, entries: v });\n```\n\n(注意: 所有 entry 共享 opcId=\"me\", 但 rawText 里 companyName 不同. 我们的分组维度是 companyName, 不是 opcId. 这里修正: 把\"按 opcId 分组\"理解为\"按公司分组\", 用 parsedFields.companyName 或 rawText 头一行提取)\n\n- [ ] **Step 3: 新增 parsedCompanyName helper**\n\n在 component 里加:\n\n```tsx\nfunction parsedCompanyName(entry: { entryType: string | null; entryDimension: string | null; rawText: string }): string {\n  // 优先级 1: 解析 rawText 头部 \"## 1. 工商信息\" 段中的 \"- 名称:\"\n  const match = entry.rawText.match(/^-\\s*名称[:：]\\s*([^\\n]+)/m);\n  if (match) return match[1].trim().slice(0, 30);\n  // 优先级 2: 用 entryDimension 当 fallback\n  return entry.entryDimension || \"未命名\";\n}\n```\n\n(临时方案, 阶段 3 重做时用 server 端 parsedFields 字段)\n\n- [ ] **Step 4: 改渲染 (masonry → group by company)**\n\n在 JSX 里, 把原来 `entries.map((e) => <EntryCard ...)` 改成:\n\n```tsx\n{grouped.map((g) => (\n  <div key={g.opcId} style={{ breakInside: \"avoid\", marginBottom: 16 }}>\n    <div style={{\n      fontSize: 12, fontWeight: 600, color: \"rgba(255,255,255,0.9)\",\n      marginBottom: 8, paddingLeft: 4,\n    }}>\n      📇 {g.opcId} · {g.entries.length} 条\n    </div>\n    {g.entries.map((e) => <EntryCard key={e.id} entry={e} ... />)}\n  </div>\n))}\n```\n\n- [ ] **Step 5: 验证 build**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/opphub-web && npm run build 2>&1 | grep -E \"(Compiled|error|knowledge)\" | head -10\n```\n\n预期: `Compiled successfully`, 路由 `/knowledge` 仍存在\n\n- [ ] **Step 6: Commit**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/opphub-web\ngit checkout main\ngit add app/knowledge/page.tsx\ngit commit -m \"feat(knowledge): /knowledge 按 companyName 分组, 只显示现行版\"\n```\n\n---\n\n## Task 5: web /knowledge 顶部\"如何录入\"文字引导\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: 在 `<h1>` 之后, 插入引导卡片**\n\n文件 `opphub-web/app/knowledge/page.tsx`, 找到 `<h1 style={{ color: \"white\", fontSize: 24, margin: \"0 0 6px\" }}>📇 知识库</h1>`, **在它后面** 插入:\n\n```tsx\n{/* 录入引导 - skill 是录入主路 */}\n<div style={{\n  background: \"rgba(255,255,255,0.95)\",\n  border: \"1px solid #e0e7ff\",\n  borderRadius: 12,\n  padding: \"16px 20px\",\n  marginBottom: 16,\n  color: \"#1f2937\",\n  boxShadow: \"0 4px 12px rgba(0,0,0,0.08)\",\n}}>\n  <div style={{ fontSize: 15, fontWeight: 600, marginBottom: 8 }}>💡 如何录入一条知识？</div>\n  <div style={{ fontSize: 13, color: \"#4b5563\", lineHeight: 1.8, marginBottom: 8 }}>\n    录入是 openclaw skill 在你的 IM 客户端里完成的, 不在网页. 装好 <code>opphub</code> skill + plugin 后, 在飞书/钉钉/微信/Telegram 里给偶合 bot 发:\n  </div>\n  <ul style={{ fontSize: 13, color: \"#374151\", lineHeight: 1.9, margin: 0, paddingLeft: 20 }}>\n    <li><code style={codeInline}>偶合录入 [公司名]</code> — 录入公司能力画像 (skill 自动联网查 + 推断行业 + 拆 4 种能力 + 等你确认)</li>\n    <li><code style={codeInline}>偶合录入我的能力: [一段话]</code> — 录入你个人能力</li>\n    <li><code style={codeInline}>偶合录入我要找: [需求描述]</code> — 录入你想找的需求</li>\n    <li><code style={codeInline}>偶合录入关联公司 + 合同 xls</code> — 上传合同 xls 拆上下游</li>\n  </ul>\n  <div style={{ fontSize: 12, color: \"#6b7280\", marginTop: 10, paddingTop: 10, borderTop: \"1px dashed #e5e7eb\" }}>\n    bot 拆完会先给你一张「将要录入」的清单 (含 4 种 entryType: 我能提供/我想找/我的依赖/同行关系), 你回复\"确认\"才入库.\n    5 秒后, 你录入的内容会出现在这里, 系统会自动跑匹配, 通知你潜在合作方.\n    还没装 skill?<a href=\"https://github.com/mtty-ai/opphub-skill\" target=\"_blank\" rel=\"noreferrer\" style={{ color: \"#4f46e5\" }}>安装文档</a>\n  </div>\n</div>\n```\n\n并在文件底部 styles 段添加:\n\n```tsx\nconst codeInline: React.CSSProperties = {\n  background: \"#eef2ff\",\n  color: \"#4f46e5\",\n  padding: \"1px 6px\",\n  borderRadius: 4,\n  fontSize: 12,\n  fontFamily: 'ui-monospace, \"SF Mono\", Menlo, monospace',\n};\n```\n\n- [ ] **Step 2: 验证 build**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"(Compiled|error|knowledge)\" | head -10\n```\n\n预期: `Compiled successfully`\n\n- [ ] **Step 3: Commit**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/opphub-web\ngit checkout main\ngit add app/knowledge/page.tsx\ngit commit -m \"feat(knowledge): /knowledge 顶部加'如何录入'文字引导 (含确认流程说明)\"\n```\n\n---\n\n## Task 6: 部署 ECS + 终验\n\n- [ ] **Step 1: scp web 改动**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev\nscp opphub-web/app/knowledge/page.tsx opphub-ecs:/opt/1panel/www/opphub-web/app/knowledge/page.tsx\n```\n\n(skill 端是用户机器, 不需要 scp)\n\n- [ ] **Step 2: 容器 build + 重启**\n\n```bash\nssh opphub-ecs \"docker exec opphub-web sh -c 'cd /app && npm run build 2>&1 | tail -5'\"\nssh opphub-ecs \"docker restart opphub-web\"\nsleep 5\nssh opphub-ecs 'docker ps | grep opphub-web'\n```\n\n- [ ] **Step 3: 验证生产 /knowledge 有引导文字**\n\n```bash\nssh opphub-ecs 'curl -s http://localhost:3000/knowledge | grep -c \"如何录入\" || echo \"0\"'\n```\n\n预期: >= 1\n\n- [ ] **Step 4: git tag**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/opphub-web\ngit tag phase-2-confirm-group\n```\n\n---\n\n## 完成判定 (v4 修订版)\n\n- [ ] Task 1: skill 3 个 bin 输出 rawText 首行带 `<!-- opphub-raw-text-v1 -->` 头\n- [ ] Task 2: skill knowledge-card 输出 parsedFields, knowledge-submit 透传到 ingest\n- [ ] Task 3: skill knowledge-card 输出 confirmation 清单 (4 entryType 全部), submit 不带 --confirm 拒绝\n- [ ] Task 4: web `/knowledge` 按 companyName 分组, 每组只显示现行版\n- [ ] Task 5: web `/knowledge` 顶部有\"如何录入\"文字引导\n- [ ] Task 6: ECS 部署 + 引导文字在生产可见\n- git tag `phase-2-confirm-group`\n\n## YAGNI 范围 (本阶段明确不做)\n\n- ❌ /knowledge/new 表单\n- ❌ /api/knowledge/dimensions 端点\n- ❌ entryDimension enum 强制\n- ❌ rawText 强结构校验\n- ❌ skill 端做匹配 (server worker 的事)\n- ❌ skill 端做推送 (opphub-ws 的事)\n- ❌ lib/knowledge-dimensions.ts 共享 enum\n- ❌ 📌 自定义 标记\n- ❌ 录入成功 banner (skill 跑完用户已在 IM 端, web 这里不重复)\n- ❌ 矛盾 / 重复检测 (阶段 3+)\n\n## 为什么 v4 比 v3 更好\n\n- v3 只加 rawText 版本头 + web 引导 (4 文件), 是 \"格式\" 升级, 没碰核心流程\n- v4 在 v3 基础上**加 3 件事**:\n  - **确认流程** (skill 端的核心安全网, 防止乱入库)\n  - **确认清单覆盖 4 种 entryType** (不只是 ability, 包含 downstream/upstream/peer)\n  - **结构化字段** (parsedFields, 让 web 端有数据画公司卡片)\n  - **按公司分组** (web 端展示的核心组织维度, 现行版 vs 历史版区分)\n\n## 这一阶段真实价值\n\n- 录入从 \"skill 自动入库\" 改成 \"skill 拆卡 + 用户确认 + 入库\" - 防止 skill 抽错乱入\n- 4 种 entryType 全部覆盖 - 不漏需求方\n- 解析字段入库 - 未来 server 端能基于结构化数据做匹配/统计\n- web 端按公司分组 - 用户一眼看到自己有什么公司画像, 每家公司几条能力\n\nFile v5.0.0:docs/superpowers/plans/2026-07-23-phase3-modal-interactions.md\n\n# OppHub 阶段 3 修订版：「我的卡片」全部交互 + /discover 改读知识库\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** 「我的卡片」页所有交互弹窗化 (详情/编辑/删除/历史) 主页不动; tab 修复使「我在找」「同行关系」可点; /discover 改读知识库; 安装文档链接换 ClawHub.\n\n**Architecture:** 仅动 opphub-web 前端, 不改 server, 不改 skill. 弹窗用本地 React state 控制开合. /discover 切换到调 `/api/knowledge?opcId=me&entryType=...&visibility=PUBLIC` (虽然当前 server 没有 visibility=PUBLIC 过滤, 暂用 opcId=me 拉自己的 entry 看 effect, 真正 public 切换留作未来 server 任务)\n\n**Tech Stack:** Next.js 14, React, 本地 state modal, fetch\n\n## TopBar 顺序 (已落地, 不可动)\n\n```\n工作台 / 🔍 发现 / 🤝 撮合 / 📦 交易 / 💰 钱包 / 🪪 我的卡片 / 🛡️ 后台 / 退出\n```\n\n「我的卡片」在 💰 钱包 和 🛡️ 后台 之间. 任何新增 nav 项必须保持这个顺序.\n\n---\n\n## 0. 关键边界 (钉死)\n\n- **「我的卡片」 = `/knowledge` 路径不变, 名字/TopBar 改**: 已 commit (`1ffbcb8` + `630367f`)\n- **TopBar 顺序已定**: 工作台 / 发现 / 撮合 / 交易 / 钱包 / 我的卡片 / 后台 / 退出 (新加的 nav 项必须保持)\n- **弹窗化**: 详情/编辑/删除/历史 全部 React state modal, 不再跳 `/knowledge/[id]` (那页面可以删, 或者留作 SEO 兜底)\n- **`/discover` 改读知识库**: 不再调 marketplace/demands 表, 调 `/api/knowledge?opcId=me` 按 entryType 过滤\n- **ClawHub 链接**: `https://clawhub.ai/mtty-ai/skills/opphub` (Quick fix, 这次一并改)\n- **tab 修复**: 「我在找」「同行关系」实际是能点, 可能是「我现在没数据」看着像坏. 修复: 优化空状态展示, 让用户知道这 tab 是好的, 只是没数据\n\n---\n\n## 1. 任务地图 (7 个)\n\n```\nTask 0: Quick fix - ClawHub 链接替换 (独立, 立刻可做)\nTask 1: 「我的卡片」详情 modal 弹窗 (替代跳转)\nTask 2: 「我的卡片」编辑 modal 弹窗 (复用 PATCH /api/knowledge/[id])\nTask 3: 「我的卡片」删除 modal 确认 + 弹窗\nTask 4: 「我的卡片」历史版本 modal 弹窗 (复用 GET /api/knowledge/[id]/history)\nTask 5: /discover 改读 /api/knowledge (按 entryType 过滤, 按 companyName 分组)\nTask 6: 部署 ECS + 终验\n```\n\n---\n\n## Task 0: Quick fix - ClawHub 链接替换\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: sed 替换链接**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/opphub-web\nsed -i 's|https://github.com/mtty-ai/opphub-skill|https://clawhub.ai/mtty-ai/skills/opphub|g' app/knowledge/page.tsx\ngrep \"安装文档\" app/knowledge/page.tsx\n```\n\n预期: grep 出 \"安装文档\" 那一行的 href 已经是 clawhub.ai\n\n- [ ] **Step 2: Commit**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/opphub-web\ngit add app/knowledge/page.tsx\ngit commit -m \"fix(ui): 安装文档链接改 ClawHub\"\n```\n\n---\n\n## Task 1: 「我的卡片」详情 modal 弹窗\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: 加 detailModal state + 渲染**\n\n文件 `opphub-web/app/knowledge/page.tsx`, 在 component 内部 (大约 `const [entries, setEntries] = ...` 附近) 加:\n\n```tsx\nconst [detailModal, setDetailModal] = useState<EntryShape | null>(null);\n```\n\n并在 `EntryCard` 上, 把 `<a href=...>` 跳转改成 button 弹 modal:\n\n(找到 `function EntryCard` 那个组件, 把 `<a href={...}>` 替换成 `<button onClick=...>`)\n\n```tsx\nfunction EntryCard({ entry, onClick }: { entry: EntryShape; onClick: () => void }) {\n  const preview = entry.rawText.slice(0, 200);\n  return (\n    <button\n      onClick={onClick}\n      style={{\n        background: \"white\",\n        borderRadius: 12,\n        padding: 16,\n        cursor: \"pointer\",\n        textAlign: \"left\",\n        width: \"100%\",\n        border: \"none\",\n      }}\n    >\n      <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\", marginBottom: 6 }}>{entry.entryDimension ?? \"未命名\"}</div>\n      <div style={{ fontSize: 13, color: \"#4b5563\", lineHeight: 1.6, marginBottom: 6, whiteSpace: \"pre-wrap\" }}>{preview}{entry.rawText.length > 200 ? \"...\" : \"\"}</div>\n      <div style={{ fontSize: 11, color: \"#9ca3af\" }}>更新: {new Date(entry.updatedAt).toLocaleString(\"zh-CN\")}</div>\n    </button>\n  );\n}\n```\n\n并修改 `g.entries.map((e) => <EntryCard key={e.id} entry={e} />)` →\n\n```tsx\n{g.entries.map((e) => (\n  <EntryCard key={e.id} entry={e} onClick={() => setDetailModal(e)} />\n))}\n```\n\n- [ ] **Step 2: 加 DetailModal 组件 (在 EntryCard 之后定义)**\n\n```tsx\nfunction DetailModal({ entry, onClose, onEdit, onDelete, onHistory }: {\n  entry: EntryShape;\n  onClose: () => void;\n  onEdit: () => void;\n  onDelete: () => void;\n  onHistory: () => void;\n}) {\n  return (\n    <div style={modalMask} onClick={onClose}>\n      <div style={modalCard} onClick={(e) => e.stopPropagation()}>\n        <div style={modalHead}>\n          <div>\n            <div style={{ fontSize: 12, color: \"#9ca3af\" }}>{entry.entryType ?? \"—\"}</div>\n            <div style={{ fontSize: 18, fontWeight: 600, color: \"#1f2937\" }}>{entry.entryDimension ?? \"未命名\"}</div>\n          </div>\n          <button onClick={onClose} style={modalClose}>×</button>\n        </div>\n        <pre style={rawTextBlock}>{entry.rawText}</pre>\n        <div style={modalFoot}>\n          <button onClick={onEdit} style={linkBtn}>编辑</button>\n          <button onClick={onHistory} style={histBtn}>📜 历史</button>\n          <button onClick={onDelete} style={deleteBtn}>删除</button>\n        </div>\n      </div>\n    </div>\n  );\n}\n```\n\n(在 component 末尾, 在 EntryCard 函数后面定义)\n\n- [ ] **Step 3: 加 modal styles (在文件底部 styles 段)**\n\n在文件底部 styles 段 (例如 modalMask / modalCard / modalHead / modalClose / modalFoot / rawTextBlock / linkBtn / histBtn / deleteBtn 附近) 加:\n\n```tsx\nconst modalMask: React.CSSProperties = {\n  position: \"fixed\", inset: 0,\n  background: \"rgba(0,0,0,0.5)\",\n  display: \"flex\", alignItems: \"center\", justifyContent: \"center\",\n  zIndex: 999, padding: 16,\n};\nconst modalCard: React.CSSProperties = {\n  background: \"white\", borderRadius: 12,\n  width: \"100%\", maxWidth: 640, maxHeight: \"85vh\",\n  display: \"flex\", flexDirection: \"column\",\n  boxShadow: \"0 20px 60px rgba(0,0,0,0.3)\",\n};\nconst modalHead: React.CSSProperties = {\n  padding: 16, borderBottom: \"1px solid #e5e7eb\",\n  display: \"flex\", justifyContent: \"space-between\", alignItems: \"flex-start\",\n};\nconst modalClose: React.CSSProperties = {\n  background: \"transparent\", border: \"none\", fontSize: 24,\n  color: \"#6b7280\", cursor: \"pointer\", lineHeight: 1,\n};\nconst modalFoot: React.CSSProperties = {\n  padding: 12, borderTop: \"1px solid #e5e7eb\",\n  display: \"flex\", gap: 8, justifyContent: \"flex-end\",\n};\nconst rawTextBlock: React.CSSProperties = {\n  padding: 16, margin: 0, flex: 1, overflow: \"auto\",\n  fontSize: 13, lineHeight: 1.7, color: \"#374151\",\n  whiteSpace: \"pre-wrap\", wordBreak: \"break-word\",\n  fontFamily: 'ui-monospace, \"SF Mono\", Menlo, monospace',\n};\nconst linkBtn: React.CSSProperties = {\n  padding: \"6px 14px\", background: \"#4f46e5\", color: \"white\",\n  border: \"none\", borderRadius: 6, fontSize: 13, cursor: \"pointer\",\n};\nconst histBtn: React.CSSProperties = {\n  padding: \"6px 14px\", background: \"#eef2ff\", color: \"#4f46e5\",\n  border: \"1px solid #c7d2fe\", borderRadius: 6, fontSize: 13, cursor: \"pointer\",\n};\nconst deleteBtn: React.CSSProperties = {\n  padding: \"6px 14px\", background: \"#fef2f2\", color: \"#dc2626\",\n  border: \"1px solid #fca5a5\", borderRadius: 6, fontSize: 13, cursor: \"pointer\",\n};\n```\n\n- [ ] **Step 4: 渲染 DetailModal**\n\n在 `KnowledgePage` JSX 末尾, `</div>` 后, `</main>` 前加:\n\n```tsx\n{detailModal && (\n  <DetailModal\n    entry={detailModal}\n    onClose={() => setDetailModal(null)}\n    onEdit={() => { setEditModal(detailModal); setDetailModal(null); }}\n    onDelete={() => { setDeleteModal(detailModal); setDetailModal(null); }}\n    onHistory={() => { setHistoryModal(detailModal.id); setDetailModal(null); }}\n  />\n)}\n```\n\n(Tasks 2-4 会实现 edit/delete/history modal 的 setXxxModal state, 先把 setEditModal / setDeleteModal / setHistoryModal state 加上, 即使 Tasks 2-4 才用)\n\n- [ ] **Step 5: 预先加 editModal / deleteModal / historyModal state**\n\n在 component 顶部 (与 detailModal 一起):\n\n```tsx\nconst [editModal, setEditModal] = useState<EntryShape | null>(null);\nconst [deleteModal, setDeleteModal] = useState<EntryShape | null>(null);\nconst [historyModal, setHistoryModal] = useState<string | null>(null);  // entry id\n```\n\n- [ ] **Step 6: Build 验证**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error\" | head -3\n```\n\n预期: Compiled successfully\n\n- [ ] **Step 7: Commit**\n\n```bash\ngit add app/knowledge/page.tsx\ngit commit -m \"feat(knowledge): 详情 modal 弹窗化 (替代跳转 /knowledge/[id])\"\n```\n\n---\n\n## Task 2: 「我的卡片」编辑 modal 弹窗\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: 加 EditModal 组件**\n\n在 `DetailModal` 函数后面定义:\n\n```tsx\nfunction EditModal({ entry, onClose, onSaved }: {\n  entry: EntryShape;\n  onClose: () => void;\n  onSaved: (newText: string) => void;\n}) {\n  const apiBase = process.env.NEXT_PUBLIC_API_BASE || \"\";\n  const [text, setText] = useState(entry.rawText);\n  const [saving, setSaving] = useState(false);\n\n  async function save() {\n    setSaving(true);\n    try {\n      const token = localStorage.getItem(\"opphubToken\") || \"\";\n      const r = await fetch(apiBase + \"/api/knowledge/\" + entry.id, {\n        method: \"PATCH\",\n        headers: { \"content-type\": \"application/json\", authorization: \"Bearer \" + token },\n        body: JSON.stringify({ rawText: text }),\n      });\n      const j = await r.json();\n      if (j?.ok) {\n        onSaved(text);\n      } else {\n        alert(\"保存失败: \" + (j?.message ?? \"未知错误\"));\n      }\n    } catch (e: any) {\n      alert(\"保存异常: \" + (e?.message ?? String(e)));\n    } finally {\n      setSaving(false);\n    }\n  }\n\n  return (\n    <div style={modalMask} onClick={onClose}>\n      <div style={modalCard} onClick={(e) => e.stopPropagation()}>\n        <div style={modalHead}>\n          <div>\n            <div style={{ fontSize: 12, color: \"#9ca3af\" }}>编辑</div>\n            <div style={{ fontSize: 18, fontWeight: 600, color: \"#1f2937\" }}>{entry.entryDimension ?? \"未命名\"}</div>\n          </div>\n          <button onClick={onClose} style={modalClose}>×</button>\n        </div>\n        <textarea\n          value={text}\n          onChange={(e) => setText(e.target.value)}\n          style={{\n            ...rawTextBlock,\n            border: \"none\",\n            flex: 1,\n            fontFamily: \"inherit\",\n            resize: \"none\",\n          }}\n        />\n        <div style={modalFoot}>\n          <button onClick={onClose} style={{ ...linkBtn, background: \"#f3f4f6\", color: \"#374151\" }}>取消</button>\n          <button onClick={save} disabled={saving} style={linkBtn}>{saving ? \"保存中...\" : \"保存\"}</button>\n        </div>\n      </div>\n    </div>\n  );\n}\n```\n\n- [ ] **Step 2: 渲染 EditModal**\n\n在 `KnowledgePage` JSX 末尾 (与 DetailModal 一起):\n\n```tsx\n{editModal && (\n  <EditModal\n    entry={editModal}\n    onClose={() => setEditModal(null)}\n    onSaved={(newText) => {\n      setCaps((prev) => (prev ?? []).map((c) => c.id === editModal.id ? { ...c, rawText: newText, updatedAt: new Date().toISOString() } : c));\n      setEditModal(null);\n    }}\n  />\n)}\n```\n\n- [ ] **Step 3: Build + Commit**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error\" | head -3\ngit add app/knowledge/page.tsx\ngit commit -m \"feat(knowledge): 编辑 modal 弹窗 (PATCH rawText)\"\n```\n\n---\n\n## Task 3: 「我的卡片」删除确认 modal\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: 加 DeleteModal 组件**\n\n```tsx\nfunction DeleteModal({ entry, onClose, onDeleted }: {\n  entry: EntryShape;\n  onClose: () => void;\n  onDeleted: () => void;\n}) {\n  const apiBase = process.env.NEXT_PUBLIC_API_BASE || \"\";\n  const [deleting, setDeleting] = useState(false);\n\n  async function doDelete() {\n    setDeleting(true);\n    try {\n      const token = localStorage.getItem(\"opphubToken\") || \"\";\n      const r = await fetch(apiBase + \"/api/knowledge/\" + entry.id, {\n        method: \"DELETE\",\n        headers: { authorization: \"Bearer \" + token },\n      });\n      const j = await r.json();\n      if (j?.ok) {\n        onDeleted();\n      } else {\n        alert(\"删除失败: \" + (j?.message ?? \"未知错误\"));\n      }\n    } catch (e: any) {\n      alert(\"删除异常: \" + (e?.message ?? String(e)));\n    } finally {\n      setDeleting(false);\n    }\n  }\n\n  return (\n    <div style={modalMask} onClick={onClose}>\n      <div style={{ ...modalCard, maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>\n        <div style={modalHead}>\n          <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\" }}>🗑️ 删除这条卡片？</div>\n          <button onClick={onClose} style={modalClose}>×</button>\n        </div>\n        <div style={{ padding: 16, fontSize: 13, color: \"#4b5563\", lineHeight: 1.7 }}>\n          将删除 <strong>{entry.entryDimension ?? \"未命名\"}</strong>。删除后不可恢复 (软删, 30 天内可联系运维恢复)。\n        </div>\n        <div style={modalFoot}>\n          <button onClick={onClose} style={{ ...linkBtn, background: \"#f3f4f6\", color: \"#374151\" }}>取消</button>\n          <button onClick={doDelete} disabled={deleting} style={deleteBtn}>{deleting ? \"删除中...\" : \"确认删除\"}</button>\n        </div>\n      </div>\n    </div>\n  );\n}\n```\n\n- [ ] **Step 2: 渲染 DeleteModal**\n\n```tsx\n{deleteModal && (\n  <DeleteModal\n    entry={deleteModal}\n    onClose={() => setDeleteModal(null)}\n    onDeleted={() => {\n      setCaps((prev) => (prev ?? []).filter((c) => c.id !== deleteModal.id));\n      setDeleteModal(null);\n    }}\n  />\n)}\n```\n\n- [ ] **Step 3: Build + Commit**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error\" | head -3\ngit add app/knowledge/page.tsx\ngit commit -m \"feat(knowledge): 删除确认 modal (DELETE 软删)\"\n```\n\n---\n\n## Task 4: 「我的卡片」历史版本 modal 弹窗\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: 加 HistoryModal 组件**\n\n```tsx\ntype HistoryVersion = {\n  id: string;\n  title: string;\n  createdAt: string;\n  supersededAt: string | null;\n  isCurrent: boolean;\n};\n\nfunction HistoryModal({ entryId, onClose }: { entryId: string; onClose: () => void }) {\n  const apiBase = process.env.NEXT_PUBLIC_API_BASE || \"\";\n  const [versions, setVersions] = useState<HistoryVersion[] | null>(null);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    const token = localStorage.getItem(\"opphubToken\") || \"\";\n    fetch(apiBase + \"/api/knowledge/\" + entryId + \"/history\", {\n      headers: { authorization: \"Bearer \" + token },\n    })\n      .then((r) => r.json())\n      .then((j) => {\n        if (j?.ok && Array.isArray(j.versions)) setVersions(j.versions);\n        else setError(j?.message ?? \"暂无历史版本\");\n      })\n      .catch((e) => setError(e?.message ?? \"网络错误\"));\n  }, [entryId, apiBase]);\n\n  return (\n    <div style={modalMask} onClick={onClose}>\n      <div style={modalCard} onClick={(e) => e.stopPropagation()}>\n        <div style={modalHead}>\n          <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\" }}>📜 版本历史</div>\n          <button onClick={onClose} style={modalClose}>×</button>\n        </div>\n        <div style={{ padding: 8, flex: 1, overflow: \"auto\" }}>\n          {versions === null && <div style={{ padding: 16, textAlign: \"center\", color: \"#9ca3af\" }}>加载中...</div>}\n          {error && <div style={{ padding: 16, color: \"#dc2626\" }}>⚠️ {error}</div>}\n          {versions && versions.length === 0 && <div style={{ padding: 16, color: \"#9ca3af\" }}>暂无历史版本</div>}\n          {versions && versions.map((v, i) => (\n            <div key={v.id} style={{ padding: \"10px 12px\", borderBottom: \"1px solid #f3f4f6\" }}>\n              <div style={{ display: \"flex\", alignItems: \"center\", gap: 8, marginBottom: 4 }}>\n                <span style={{ fontSize: 13, fontWeight: 600, color: \"#4f46e5\", fontFamily: 'ui-monospace, \"SF Mono\", Menlo, monospace' }}>v{versions.length - i}</span>\n                <span style={{ fontSize: 12, color: \"#9ca3af\" }}>{new Date(v.createdAt).toLocaleString(\"zh-CN\", { hour12: false })}</span>\n                {v.isCurrent && <span style={{ fontSize: 10, padding: \"1px 6px\", background: \"#d1fae5\", color: \"#059669\", borderRadius: 8, fontWeight: 600 }}>现行</span>}\n              </div>\n              <div style={{ fontSize: 12, color: \"#6b7280\" }}>{v.title}</div>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n```\n\n- [ ] **Step 2: 渲染 HistoryModal**\n\n```tsx\n{historyModal && (\n  <HistoryModal entryId={historyModal} onClose={() => setHistoryModal(null)} />\n)}\n```\n\n- [ ] **Step 3: Build + Commit**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error\" | head -3\ngit add app/knowledge/page.tsx\ngit commit -m \"feat(knowledge): 历史版本 modal 弹窗 (GET /api/knowledge/[id]/history)\"\n```\n\n---\n\n## Task 5: /discover 改读 /api/knowledge\n\n**Files:**\n- Modify: `opphub-web/app/discover/page.tsx`\n\n- [ ] **Step 1: 改 fetch 数据源**\n\n文件 `opphub-web/app/discover/page.tsx`, 找到 `useEffect` 块 (大约 line 51-65), 替换为:\n\n```tsx\n  useEffect(() => {\n    if (!mounted) return;\n    setLoading(true);\n    const token = localStorage.getItem(\"opphubToken\") || \"\";\n    // buyer 角色: 想看供应方 → 看别人录入的 ability 卡片\n    // seller 角色: 想看需求 → 看别人录入的 downstream 卡片\n    const targetType = role === \"buyer\" ? \"ability\" : \"downstream\";\n    fetch(`${apiBase}/api/knowledge?opcId=me&entryType=${targetType}`, {\n      headers: { authorization: \"Bearer \" + token },\n    })\n      .then((r) => r.json())\n      .then((j) => {\n        const list = j?.ok && Array.isArray(j.data) ? j.data : [];\n        setCards(list);\n      })\n      .catch(() => setCards([]))\n      .finally(() => setLoading(false));\n  }, [mounted, role, apiBase]);\n```\n\n(注: 实际公开浏览需要 server 加 `visibility=PUBLIC` 过滤; 此处先实现 拉自己的 entry 看 effect, 真公开留 server 任务)\n\n- [ ] **Step 2: 替换 state 和渲染**\n\n把原来 `useState<Demand[]>([])` / `useState<OPC[]>([])` 改为 `useState<Entry[]>([])`:\n\n```tsx\ntype Entry = {\n  id: string;\n  entryType: string | null;\n  entryDimension: string | null;\n  rawText: string;\n  updatedAt: string;\n};\n\nconst [cards, setCards] = useState<Entry[]>([]);\n```\n\n(删除 Demand / OPC 类型, 替换为单一 Entry)\n\n- [ ] **Step 3: 改渲染 - 用 entry 列表, 按公司名分组**\n\n把 buyer 角色和 seller 角色的渲染合并, 改为按 companyName 分组:\n\n```tsx\n{!loading && (\n  <div style={{ background: \"white\", borderRadius: 12, padding: 16 }}>\n    <div style={{ fontSize: 14, color: \"#6b7280\", marginBottom: 12 }}>共 {cards.length} 张卡片</div>\n    {cards.length === 0 ? (\n      <div style={{ color: \"#9ca3af\", textAlign: \"center\", padding: 32 }}>暂无公开卡片</div>\n    ) : (\n      <div style={{ display: \"grid\", gap: 10, gridTemplateColumns: \"repeat(auto-fill, minmax(280px, 1fr))\" }}>\n        {cards.map((c) => (\n          <a key={c.id} href={`/knowledge/profile?opcId=me`} style={{ textDecoration: \"none\" }}>\n            <div style={{ border: \"1px solid #e5e7eb\", borderRadius: 8, padding: 12 }}>\n              <div style={{ fontSize: 12, color: \"#9ca3af\", marginBottom: 4 }}>{c.entryType}</div>\n              <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\" }}>{c.entryDimension ?? \"未命名\"}</div>\n              <div style={{ fontSize: 12, color: \"#4b5563\", marginTop: 6, lineHeight: 1.5 }}>\n                {c.rawText.slice(0, 100)}...\n              </div>\n            </div>\n          </a>\n        ))}\n      </div>\n    )}\n  </div>\n)}\n```\n\n(buyer 和 seller 用同一个渲染, 因为现在数据源已经按 entryType 过滤)\n\n- [ ] **Step 4: Build + Commit**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error|discover\" | head -10\ngit add app/discover/page.tsx\ngit commit -m \"feat(discover): 改读 /api/knowledge (按 entryType 过滤, 替代 marketplace/demands 表)\"\n```\n\n---\n\n## Task 6: 部署 ECS + 终验\n\n- [ ] **Step 1: scp web 改动**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev\nscp opphub-web/app/knowledge/page.tsx opphub-ecs:/opt/1panel/www/opphub-web/app/knowledge/page.tsx\nscp opphub-web/app/discover/page.tsx opphub-ecs:/opt/1panel/www/opphub-web/app/discover/page.tsx\n```\n\n- [ ] **Step 2: 容器 build + 重启**\n\n```bash\nssh opphub-ecs \"docker exec opphub-web sh -c 'cd /app && npm run build 2>&1 | tail -5'\"\nssh opphub-ecs \"docker restart opphub-web\"\nsleep 5\n```\n\n- [ ] **Step 3: 验证**\n\n```bash\n# TopBar 含 \"我的卡片\"\nssh opphub-ecs 'curl -s http://localhost:3000/knowledge | grep -c \"我的卡片\" || echo \"0\"'\n\n# /discover 200\nssh opphub-ecs 'curl -s -o /dev/null -w \"%{http_code}\\n\" http://localhost:3000/discover'\n```\n\n预期: 前者 >= 1, 后者 200\n\n- [ ] **Step 4: git tag**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/opphub-web\ngit tag phase-3-modal-interactions\n```\n\n---\n\n## 完成判定 (v5 修订版)\n\n- [ ] Task 0: ClawHub 链接替换 (github.com → clawhub.ai)\n- [ ] Task 1: 「我的卡片」详情 modal 弹窗 (替代跳转)\n- [ ] Task 2: 「我的卡片」编辑 modal (PATCH rawText)\n- [ ] Task 3: 「我的卡片」删除确认 modal (DELETE 软删)\n- [ ] Task 4: 「我的卡片」历史版本 modal (GET /api/knowledge/[id]/history)\n- [ ] Task 5: /discover 改读 /api/knowledge, 按 entryType 过滤\n- [ ] Task 6: ECS 部署 + 终验\n- TopBar 显示「🪪 我的卡片」+ 位置在 💰 钱包 和 🛡️ 后台 之间 (已 commit `1ffbcb8` + `630367f`)\n- git tag `phase-3-modal-interactions`\n\n## YAGNI 范围 (本阶段明确不做)\n\n- ❌ server 端加 `visibility=PUBLIC` 过滤 (留作 server 任务)\n- ❌ 撮合匹配对页面 (阶段 3 后续或阶段 4)\n- ❌ 推送通道 (阶段 5)\n- ❌ lib/knowledge-dimensions 共享 enum\n- ❌ entryDimension enum 强制\n- ❌ `/knowledge/[id]` 页面删除 (留作 SEO 兜底, 不再被引)\n- ❌ ClawHub 链接替换 (这次只更新「我的卡片」name, ClawHub 链接留作下次再改 — 实际是本次也应该改, 补充在 Task 0 之外的 quick fix 里)\n\n## ClawHub 链接 quick fix (Task 0 - 已 commit 但忘记加)\n\n实际上 ClawHub 链接 `https://clawhub.ai/mtty-ai/skills/opphub` 应该立刻替换. 这条单 commit:\n\n```bash\ncd opphub-web\n# app/knowledge/page.tsx line 103 把 github.com 替换成 clawhub.ai/mtty-ai/skills\nsed -i 's|https://github.com/mtty-ai/opphub-skill|https://clawhub.ai/mtty-ai/skills/opphub|g' app/knowledge/page.tsx\ngit add app/knowledge/page.tsx\ngit commit -m \"fix(ui): 安装文档链接改 ClawHub\"\n```\n\n(在 Task 0 之外的 quick fix, 部署在 Task 6 同一批)\n\nFile v5.0.0:docs/superpowers/plans/2026-07-23-phase4-bug-fixes.md\n\n# OppHub 阶段 4 修订版：6 个 bug + 1 个产品问题\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** 修 4 个 UI bug, 跑一次 skill 模拟录入, /discover 视觉区分, 文案微调. 1 个紧急事: 恢复丢失的 API 文件.\n\n**Architecture:** 修 opphub-web 前端 + 恢复 opphub-web API routes + 跑 opphub-skill 模拟 (不部署 skill). 不改 server 业务逻辑.\n\n**Tech Stack:** Next.js 14, React, fetch\n\n---\n\n## 0. 关键事实\n\n- **API 文件丢失**: `app/api/knowledge/[id]/route.ts` 和 `app/api/knowledge/[id]/history/route.ts` 在之前 server migration 中被误删, 不在 git history. 必须重建 (Task 0 紧急).\n- **「我的卡片」文案**: 引导区文案 \"如何录入一条知识？\" 改 \"如何录入一条卡片？\"\n- **/discover 当前 buyer/seller 内容一样**: 因为两 tab 都查同 source, 之前忽略. 现在做视觉区分.\n- **skill 模拟**: 在本机跑 `opphub-skill` 三个 bin (discover + card + submit), 录入 上海睿驰嘉禾数字传媒科技有限公司\n\n---\n\n## 1. 任务地图 (8 个)\n\n```\nTask 0: 紧急 - 恢复丢失的 API routes\nTask 1: EditModal 高度修复 (modal 撑高 + textarea 固定高度)\nTask 2: 历史版本查看详情 (点行展开 rawText, server 加 rawText 字段)\nTask 3: Delete 错误处理改进 (fetch + try-catch + 准确报错)\nTask 4: 引导文案改 \"如何录入一条卡片\"\nTask 5: /discover 视觉区分 (背景色 + emoji + label)\nTask 6: 跑 skill 模拟 上海睿驰嘉禾 录入\nTask 7: 部署 ECS + 终验\n```\n\n---\n\n## Task 0: 紧急 - 恢复丢失的 API routes\n\n**Files:**\n- Create: `opphub-web/app/api/knowledge/[id]/route.ts` (DELETE + PATCH)\n- Create: `opphub-web/app/api/knowledge/[id]/history/route.ts` (GET history)\n\n- [ ] **Step 1: 重建 [id]/route.ts (DELETE + PATCH)**\n\n文件 `opphub-web/app/api/knowledge/[id]/route.ts`:\n\n```ts\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { prisma } from \"@/packages/db/prisma\";\nimport jwt from \"jsonwebtoken\";\nimport { getJwtSecret } from \"@/packages/db/jwt\";\nimport { createHash } from \"node:crypto\";\n\nexport const dynamic = \"force-dynamic\";\nexport const runtime = \"nodejs\";\n\nfunction getOpcIdFromToken(req: NextRequest): string | null {\n  const auth = req.headers.get(\"authorization\");\n  if (!auth?.startsWith(\"Bearer \")) return null;\n  try {\n    const payload = jwt.verify(auth.slice(7), getJwtSecret()) as any;\n    return payload?.opcId ?? payload?.sub ?? null;\n  } catch {\n    return null;\n  }\n}\n\nfunction sha256(s: string): string {\n  return createHash(\"sha256\").update(s).digest(\"hex\");\n}\n\nexport async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {\n  const tokenOpcId = getOpcIdFromToken(req);\n  if (!tokenOpcId) {\n    return NextResponse.json({ ok: false, error: \"unauthorized\", message: \"token 无效或过期\" }, { status: 401 });\n  }\n\n  const body = await req.json().catch(() => ({}));\n  const rawText = typeof body?.rawText === \"string\" ? body.rawText : null;\n  if (!rawText) {\n    return NextResponse.json({ ok: false, error: \"missing_rawtext\", message: \"需要 rawText\" }, { status: 400 });\n  }\n\n  const entry = await prisma.opcKnowledgeEntry.findUnique({ where: { id: params.id } });\n  if (!entry) {\n    return NextResponse.json({ ok: false, error: \"not_found\", message: \"条目不存在\" }, { status: 404 });\n  }\n  if (entry.opcId !== tokenOpcId) {\n    return NextResponse.json({ ok: false, error: \"forbidden\", message: \"不能编辑其他用户的条目\" }, { status: 403 });\n  }\n\n  const now = new Date();\n  const newContentHash = sha256(rawText);\n  const versionKey = sha256(entry.opcId + \"|\" + entry.id + \"|\" + now.getTime());\n\n  const [, newEntry] = await prisma.$transaction([\n    prisma.opcKnowledgeEntry.update({\n      where: { id: entry.id },\n      data: { supersededAt: now, idempotencyKey: null },\n    }),\n    prisma.opcKnowledgeEntry.create({\n      data: {\n        opcId: entry.opcId,\n        sourceType: entry.sourceType,\n        rawText,\n        status: \"done\",\n        entryType: entry.entryType,\n        entryDimension: entry.entryDimension,\n        idempotencyKey: versionKey,\n        contentHash: newContentHash,\n        visibility: entry.visibility,\n        previousEntryId: entry.id,\n      },\n    }),\n  ]);\n\n  return NextResponse.json({\n    ok: true,\n    action: \"updated\",\n    entryId: newEntry.id,\n    previousEntryId: entry.id,\n    version: \"new_version_created\",\n  });\n}\n\nexport async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {\n  const tokenOpcId = getOpcIdFromToken(req);\n  if (!tokenOpcId) {\n    return NextResponse.json({ ok: false, error: \"unauthorized\", message: \"token 无效或过期\" }, { status: 401 });\n  }\n\n  const entry = await prisma.opcKnowledgeEntry.findUnique({ where: { id: params.id } });\n  if (!entry) {\n    return NextResponse.json({ ok: false, error: \"not_found\", message: \"条目不存在\" }, { status: 404 });\n  }\n  if (entry.opcId !== tokenOpcId) {\n    return NextResponse.json({ ok: false, error: \"forbidden\", message: \"不能删除其他用户的条目\" }, { status: 403 });\n  }\n\n  await prisma.opcKnowledgeEntry.update({\n    where: { id: params.id },\n    data: { deletedAt: new Date() },\n  });\n\n  return NextResponse.json({ ok: true });\n}\n```\n\n- [ ] **Step 2: 重建 [id]/history/route.ts (GET history chain + rawText)**\n\n文件 `opphub-web/app/api/knowledge/[id]/history/route.ts`:\n\n```ts\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { prisma } from \"@/packages/db/prisma\";\nimport jwt from \"jsonwebtoken\";\nimport { getJwtSecret } from \"@/packages/db/jwt\";\n\nexport const dynamic = \"force-dynamic\";\nexport const runtime = \"nodejs\";\n\nfunction getOpcIdFromToken(req: NextRequest): string | null {\n  const auth = req.headers.get(\"authorization\");\n  if (!auth?.startsWith(\"Bearer \")) return null;\n  try {\n    const payload = jwt.verify(auth.slice(7), getJwtSecret()) as any;\n    return payload?.opcId ?? payload?.sub ?? null;\n  } catch {\n    return null;\n  }\n}\n\nfunction extractTitle(rawText: string): string {\n  const lines = rawText.split(\"\\n\").map(l => l.trim()).filter(Boolean);\n  for (const line of lines) {\n    const m = line.match(/^(?:能力|技能|公司|服务|经验|title|skill|company|service|experience)[:：]\\s*(.+)$/i);\n    if (m) return m[1].trim().slice(0, 60);\n  }\n  return (lines[0] ?? \"未命名\").slice(0, 60);\n}\n\nexport async function GET(req: NextRequest, { params }: { params: { id: string } }) {\n  const tokenOpcId = getOpcIdFromToken(req);\n  if (!tokenOpcId) {\n    return NextResponse.json({ ok: false, error: \"unauthorized\", message: \"token 无效或过期\" }, { status: 401 });\n  }\n\n  const entry = await prisma.opcKnowledgeEntry.findUnique({\n    where: { id: params.id },\n    select: { id: true, opcId: true, previousEntryId: true, rawText: true, createdAt: true, supersededAt: true },\n  });\n  if (!entry) {\n    return NextResponse.json({ ok: false, error: \"not_found\", message: \"条目不存在\" }, { status: 404 });\n  }\n  if (entry.opcId !== tokenOpcId) {\n    return NextResponse.json({ ok: false, error: \"forbidden\", message: \"不能查看其他用户的条目版本历史\" }, { status: 403 });\n  }\n\n  // 顺链: currentEntryId + 前链 (entryId 上溯找前链)\n  const versions = [];\n  let currentId: string | null = params.id;\n  let depth = 0;\n  while (currentId && depth < 50) {\n    const v = await prisma.opcKnowledgeEntry.findUnique({\n      where: { id: currentId },\n      select: { id: true, rawText: true, createdAt: true, previousEntryId: true, supersededAt: true },\n    });\n    if (!v) break;\n    versions.push({\n      id: v.id,\n      title: extractTitle(v.rawText),\n      rawText: v.rawText,\n      createdAt: v.createdAt.toISOString(),\n      supersededAt: v.supersededAt?.toISOString() ?? null,\n      isCurrent: v.supersededAt === null,\n    });\n    currentId = v.previousEntryId;\n    depth++;\n  }\n\n  return NextResponse.json({\n    ok: true,\n    entryId: params.id,\n    versions,\n    total: versions.length,\n  });\n}\n```\n\n- [ ] **Step 3: 验证 build**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error\" | head -3\n```\n\n预期: Compiled successfully, 路由 `/api/knowledge/[id]` 和 `/api/knowledge/[id]/history` 出现\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add app/api/knowledge/\ngit commit -m \"fix(api): 重建 /api/knowledge/[id] PATCH+DELETE, /api/knowledge/[id]/history (历史 chain + rawText)\"\n```\n\n---\n\n## Task 1: EditModal 高度修复 (modal 撑高 + textarea 固定高度)\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: 改 EditModal 容器高度**\n\n找到 `function EditModal` 里的 `return (` 那段, 把外层 modalCard 改为:\n\n```tsx\n      <div style={{ ...modalCard, maxWidth: 720, maxHeight: \"90vh\" }} onClick={(e) => e.stopPropagation()}>\n```\n\n- [ ] **Step 2: 改 textarea 固定高度 (可滚动)**\n\n找到 EditModal 里的 `<textarea ...>` 块, 替换为:\n\n```tsx\n        <textarea\n          value={text}\n          onChange={(e) => setText(e.target.value)}\n          style={{\n            ...rawTextBlock,\n            border: \"none\",\n            fontFamily: \"inherit\",\n            resize: \"none\",\n            minHeight: 500,\n            maxHeight: \"calc(90vh - 200px)\",\n          }}\n        />\n```\n\n(注: 不要 `flex: 1`, 改用 minHeight: 500. maxHeight 留出 200px 给 header + footer)\n\n- [ ] **Step 3: Build + Commit**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error\" | head -3\ngit add app/knowledge/page.tsx\ngit commit -m \"fix(knowledge): EditModal 撑高 + textarea 固定 500px (可滚动)\"\n```\n\n---\n\n## Task 2: 历史版本点行展开 rawText (复用 Task 0 新加的 rawText 字段)\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: HistoryModal 加点击展开 state**\n\n找到 `HistoryModal` 函数, 替换为:\n\n```tsx\nfunction HistoryModal({ entryId, onClose }: { entryId: string; onClose: () => void }) {\n  const apiBase = process.env.NEXT_PUBLIC_API_BASE || \"\";\n  const [versions, setVersions] = useState<HistoryVersion[] | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const [expandedId, setExpandedId] = useState<string | null>(null);\n\n  useEffect(() => {\n    const token = localStorage.getItem(\"opphubToken\") || \"\";\n    fetch(apiBase + \"/api/knowledge/\" + entryId + \"/history\", {\n      headers: { authorization: \"Bearer \" + token },\n    })\n      .then((r) => r.json())\n      .then((j) => {\n        if (j?.ok && Array.isArray(j.versions)) setVersions(j.versions);\n        else setError(j?.message ?? \"暂无历史版本\");\n      })\n      .catch((e) => setError(e?.message ?? \"网络错误\"));\n  }, [entryId, apiBase]);\n\n  return (\n    <div style={modalMask} onClick={onClose}>\n      <div style={modalCard} onClick={(e) => e.stopPropagation()}>\n        <div style={modalHead}>\n          <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\" }}>📜 版本历史</div>\n          <button onClick={onClose} style={modalClose}>×</button>\n        </div>\n        <div style={{ padding: 8, flex: 1, overflow: \"auto\" }}>\n          {versions === null && <div style={{ padding: 16, textAlign: \"center\", color: \"#9ca3af\" }}>加载中...</div>}\n          {error && <div style={{ padding: 16, color: \"#dc2626\" }}>⚠️ {error}</div>}\n          {versions && versions.length === 0 && <div style={{ padding: 16, color: \"#9ca3af\" }}>暂无历史版本</div>}\n          {versions && versions.map((v, i) => {\n            const isExpanded = expandedId === v.id;\n            return (\n              <div key={v.id} style={{ borderBottom: \"1px solid #f3f4f6\" }}>\n                <div\n                  onClick={() => setExpandedId(isExpanded ? null : v.id)}\n                  style={{ padding: \"10px 12px\", cursor: \"pointer\", display: \"flex\", alignItems: \"center\", gap: 8, background: isExpanded ? \"#f9fafb\" : \"transparent\" }}\n                >\n                  <span style={{ fontSize: 13, fontWeight: 600, color: \"#4f46e5\", fontFamily: 'ui-monospace, \"SF Mono\", Menlo, monospace' }}>v{versions.length - i}</span>\n                  <span style={{ fontSize: 12, color: \"#9ca3af\" }}>{new Date(v.createdAt).toLocaleString(\"zh-CN\", { hour12: false })}</span>\n                  {v.isCurrent && <span style={{ fontSize: 10, padding: \"1px 6px\", background: \"#d1fae5\", color: \"#059669\", borderRadius: 8, fontWeight: 600 }}>现行</span>}\n                  <span style={{ fontSize: 11, color: \"#9ca3af\", marginLeft: \"auto\" }}>{isExpanded ? \"▾\" : \"▸\"}</span>\n                </div>\n                <div style={{ padding: \"0 12px 8px 12px\", fontSize: 12, color: \"#6b7280\" }}>{v.title}</div>\n                {isExpanded && (\n                  <pre style={{ margin: \"0 12px 12px 12px\", padding: 10, background: \"#f3f4f6\", borderRadius: 6, fontSize: 12, lineHeight: 1.6, color: \"#374151\", whiteSpace: \"pre-wrap\", wordBreak: \"break-word\", fontFamily: 'ui-monospace, \"SF Mono\", Menlo, monospace' }}>{v.rawText || \"(无内容)\"}</pre>\n                )}\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n```\n\n- [ ] **Step 2: Build + Commit**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error\" | head -3\ngit add app/knowledge/page.tsx\ngit commit -m \"feat(knowledge): 历史版本点行展开 rawText 详情\"\n```\n\n---\n\n## Task 3: Delete 错误处理改进 (fetch + try-catch + 准确报错)\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: DeleteModal 改用错误 state + 显式渲染**\n\n找到 DeleteModal 函数, 替换为:\n\n```tsx\nfunction DeleteModal({ entry, onClose, onDeleted }: {\n  entry: EntryShape;\n  onClose: () => void;\n  onDeleted: () => void;\n}) {\n  const apiBase = process.env.NEXT_PUBLIC_API_BASE || \"\";\n  const [deleting, setDeleting] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  async function doDelete() {\n    setDeleting(true);\n    setError(null);\n    try {\n      const token = localStorage.getItem(\"opphubToken\") || \"\";\n      const r = await fetch(apiBase + \"/api/knowledge/\" + entry.id, {\n        method: \"DELETE\",\n        headers: { authorization: \"Bearer \" + token },\n      });\n      let j: any = {};\n      try { j = await r.json(); } catch { /* non-json response */ }\n      if (r.ok && j?.ok) {\n        onDeleted();\n      } else {\n        setError(`删除失败 (HTTP ${r.status}): ${j?.message ?? j?.error ?? \"未知错误\"}`);\n      }\n    } catch (e: any) {\n      setError(`网络错误: ${e?.message ?? String(e)}`);\n    } finally {\n      setDeleting(false);\n    }\n  }\n\n  return (\n    <div style={modalMask} onClick={onClose}>\n      <div style={{ ...modalCard, maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>\n        <div style={modalHead}>\n          <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\" }}>🗑️ 删除这条卡片？</div>\n          <button onClick={onClose} style={modalClose}>×</button>\n        </div>\n        <div style={{ padding: 16, fontSize: 13, color: \"#4b5563\", lineHeight: 1.7 }}>\n          将删除 <strong>{entry.entryDimension ?? \"未命名\"}</strong>。删除后不可恢复 (软删, 30 天内可联系运维恢复)。\n        </div>\n        {error && (\n          <div style={{ margin: \"0 16px 12px 16px\", padding: 10, background: \"#fef2f2\", border: \"1px solid #fca5a5\", borderRadius: 6, color: \"#991b1b\", fontSize: 12, lineHeight: 1.5 }}>\n            ⚠️ {error}\n          </div>\n        )}\n        <div style={modalFoot}>\n          <button onClick={onClose} disabled={deleting} style={{ ...linkBtn, background: \"#f3f4f6\", color: \"#374151\" }}>取消</button>\n          <button onClick={doDelete} disabled={deleting} style={deleteBtn}>{deleting ? \"删除中...\" : \"确认删除\"}</button>\n        </div>\n      </div>\n    </div>\n  );\n}\n```\n\n(去掉 alert, 改用 setError 在 modal 体内显示)\n\n- [ ] **Step 2: Build + Commit**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error\" | head -3\ngit add app/knowledge/page.tsx\ngit commit -m \"fix(knowledge): Delete 错误在 modal 体内显示, 准确 HTTP 状态 + 异常信息\"\n```\n\n---\n\n## Task 4: 引导文案改 \"如何录入一条卡片\"\n\n**Files:**\n- Modify: `opphub-web/app/knowledge/page.tsx`\n\n- [ ] **Step 1: 改文案**\n\n找到 `💡 如何录入一条知识？`, 替换为 `💡 如何录入一条卡片？`\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add app/knowledge/page.tsx\ngit commit -m \"fix(ui): 引导文案 '如何录入一条知识？' → '如何录入一条卡片？'\"\n```\n\n---\n\n## Task 5: /discover 视觉区分 (背景色 + emoji + label)\n\n**Files:**\n- Modify: `opphub-web/app/discover/page.tsx`\n\n- [ ] **Step 1: 改 Tab 按钮加 emoji + 颜色**\n\n找到两 Tab 按钮 (在 `switchRole` 调用前那块), 替换为:\n\n```tsx\n        <button\n          onClick={() => switchRole(\"buyer\")}\n          style={{\n            padding: \"8px 16px\", borderRadius: 8, border: \"none\", cursor: \"pointer\",\n            background: role === \"buyer\" ? \"#10b981\" : \"rgba(255,255,255,0.2)\",\n            color: role === \"buyer\" ? \"white\" : \"white\",\n            fontWeight: role === \"buyer\" ? 600 : 400,\n          }}\n        >\n          🔍 我在找 (浏览供应方)\n        </button>\n        <button\n          onClick={() => switchRole(\"seller\")}\n          style={{\n            padding: \"8px 16px\", borderRadius: 8, border: \"none\", cursor: \"pointer\",\n            background: role === \"seller\" ? \"#3b82f6\" : \"rgba(255,255,255,0.2)\",\n            color: \"white\",\n            fontWeight: role === \"seller\" ? 600 : 400,\n          }}\n        >\n          📇 我能提供 (浏览需求)\n        </button>\n```\n\n(b buyer 选中绿色 #10b981, seller 选中蓝色 #3b82f6, 视觉区分)\n\n- [ ] **Step 2: 改卡片视觉 (左侧 border-color + label)**\n\n找到 cards 渲染那段, 把 `border: \"1px solid #e5e7eb\"` 改为:\n\n```tsx\n{/* role=buyer 看 ability, role=seller 看 downstream, 视觉区分 */}\n            <div style={{\n              border: \"1px solid #e5e7eb\",\n              borderLeft: `4px solid ${role === \"buyer\" ? \"#10b981\" : \"#3b82f6\"}`,\n              borderRadius: 8,\n              padding: 12,\n              background: role === \"buyer\" ? \"#f0fdf4\" : \"#eff6ff\",\n            }}>\n              <div style={{ fontSize: 11, color: role === \"buyer\" ? \"#059669\" : \"#2563eb\", fontWeight: 600, marginBottom: 4 }}>\n                {role === \"buyer\" ? \"✅ 提供方\" : \"🔍 需求方\"}\n              </div>\n              <div style={{ fontSize: 12, color: \"#9ca3af\", marginBottom: 4 }}>{c.entryType}</div>\n              <div style={{ fontSize: 16, fontWeight: 600, color: \"#1f2937\" }}>{c.entryDimension ?? \"未命名\"}</div>\n              <div style={{ fontSize: 12, color: \"#4b5563\", marginTop: 6, lineHeight: 1.5 }}>\n                {c.rawText.slice(0, 100)}...\n              </div>\n            </div>\n```\n\n- [ ] **Step 3: Build + Commit**\n\n```bash\ncd opphub-web && npm run build 2>&1 | grep -E \"Compiled|error\" | head -3\ngit add app/discover/page.tsx\ngit commit -m \"feat(discover): 视觉区分供应方 (绿) vs 需求方 (蓝)\"\n```\n\n---\n\n## Task 6: 跑 skill 模拟 上海睿驰嘉禾 录入\n\n**Files:**\n- 不写代码, 只跑 skill 命令\n\n- [ ] **Step 1: 确认 skill 端在 main 且有 rawText 版本头**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/skills/opphub\ngit log --oneline -3\ngit status\n```\n\n- [ ] **Step 2: 用 skill 模拟 discover + card + submit 流程**\n\n这一步是模拟真实 bot turn (不用 LLM, 手动喂 rawText), 验证 skill 6 步能跑通:\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/skills/opphub\n\n# 阶段 1: discover query plan\necho \"=== 阶段 1: discover query-plan ===\"\nnode bin/opphub-knowledge-discover --name \"上海睿驰嘉禾数字传媒科技有限公司\" --json 2>&1 | head -20\n\n# 阶段 1 续: discover passthrough (手动喂 rawText 模拟 bot 填完模板)\necho \"\"\necho \"=== 阶段 1 续: discover passthrough ===\"\nRAW='# 上海睿驰嘉禾数字传媒科技有限公司 · 自动画像\n\n## 1. 工商信息\n名称: 上海睿驰嘉禾数字传媒科技有限公司\n法人: 刘会冬\n注册资本: 1000万元人民币\n信用代码: 91310110MAC019T03K\n团队规模: 15-50人\n地址: 上海市杨浦区三门路200号三层302-1室\n成立时间: 2022-09-21\n\n## 2. 业务描述\n基于短视频平台, 为客户提供达人营销、内容制作、电商转化、平台代运营、用户运营与虚拟人技术应用服务的数字化传媒科技公司。\n核心成员来自易车、蓝色光标、京东、宝马等一线上市公司团队, 在用户运营、整合营销、数据产品均有丰富经验。\n尤其在汽车、金融、电商等行业积累了大量的实践经验和成功案例。\n旗下产品官网: richdigital.com.cn, ruiplus.com.cn\n\n## 3. 高管团队\nCEO: 刘会冬, 曾任职京东零售集团商业提升部站外广告业务负责人/京东黑珑业务总经理; 蓝色光标集团副总裁/移动广告董事总经理; 易车集团CIG精准营销总经理/大数据营销负责人; 18年商业化运营和管理经验。\n\n## 4. 项目案例\n- 为多家汽车品牌提供达人营销和内容制作服务\n- 为金融客户提供数字化营销解决方案\n- 为电商品牌提供电商转化和平台代运营服务\n\n## 5. 招聘岗位\n- 新媒体运营\n- 短视频编导\n- 达人商务BD\n- 内容策划\n- 数据分析师\n'\n\nnode bin/opphub-knowledge-discover \\\n  --name \"上海睿驰嘉禾数字传媒科技有限公司\" \\\n  --raw-text \"$RAW\" \\\n  --json 2>&1 | head -25\n\n# 阶段 2: card 拆卡片\necho \"\"\necho \"=== 阶段 2: knowledge-card 拆能力卡片 ===\"\nnode bin/opphub-knowledge-card \\\n  --name \"上海睿驰嘉禾数字传媒科技有限公司\" \\\n  --raw-text \"$RAW\" \\\n  --json 2>&1 | head -40\n```\n\n- [ ] **Step 3: 用 confirmation 流程拿到 cards.json, submit**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/skills/opphub\n\n# 拿 card 输出, 取 cards 数组\nnode bin/opphub-knowledge-card \\\n  --name \"上海睿驰嘉禾数字传媒科技有限公司\" \\\n  --raw-text \"$RAW\" \\\n  --json 2>&1 | python3 -c \"\nimport sys, json\nout = sys.stdin.read()\n# 找 JSON 块 (从第一个 { 开始)\nstart = out.find('{')\nj = json.loads(out[start:])\nprint('cards:', len(j.get('cards', [])))\nprint('confirmation.totalCards:', j.get('confirmation', {}).get('totalCards'))\nprint('parsedFields keys:', list(j.get('parsedFields', {}).keys()))\n# 保存到 /tmp/ruichi_cards.json\nwith open('/tmp/ruichi_cards.json', 'w') as f:\n    json.dump(j.get('cards', []), f, ensure_ascii=False, indent=2)\nprint('saved to /tmp/ruichi_cards.json')\n\"\n\n# 阶段 3: submit (需要 --confirm flag)\necho \"\"\necho \"=== 阶段 3: knowledge-submit 录入 ===\"\nnode bin/opphub-knowledge-submit \\\n  --company \"上海睿驰嘉禾数字传媒科技有限公司\" \\\n  --raw-text \"$RAW\" \\\n  --cards /tmp/ruichi_cards.json \\\n  --confirm \\\n  --json 2>&1 | head -30\n```\n\n- [ ] **Step 4: 验证录入到数据库**\n\n```bash\n# 查数据库: 列出该 opc 的所有 ability entries\nssh opphub-ecs 'docker exec -w /app opphub-web sh -c \"\nnode -e \\\"\nconst{PrismaClient}=require(\\\\\\\"@prisma/client\\\\\\\");\nconst p=new PrismaClient();\n(async()=>{\n  const r=await p.opcKnowledgeEntry.findMany({\n    where:{opcId:\\\\\\\"opc_1hz6wsjrmt1s\\\\\\\", entryType:\\\\\\\"ability\\\\\\\", supersededAt:null},\n    orderBy:{createdAt:\\\\\\\"desc\\\\\\\"}, take:10\n  });\n  for(const e of r) console.log(\\\\\\\" \\\\\\\",e.entryDimension,\\\\\\\"raw=\\\\\\\"+e.rawText.length);\n  await p.\\\\\\$disconnect();\n})();\n\\\"\"\n'\n```\n\n预期: 至少有 4 条 ability entry (达人营销, 平台代运营, 电商转化, 内容制作等)\n\n- [ ] **Step 5: 验证 web 页面能看到**\n\n```bash\nssh opphub-ecs 'curl -s \"http://localhost:3000/api/knowledge?opcId=me\" -H \"Authorization: Bearer $(curl -s -X POST http://localhost:3000/api/auth/login -H \\\"content-type: application/json\\\" -d \\\"{\\\\\\\"type\\\\\\\":\\\\\\\"email\\\\\\\",\\\\\\\"target\\\\\\\":\\\\\\\"chinabot@163.com\\\\\\\",\\\\\\\"password\\\\\\\":\\\\\\\"12345678\\\\\\\"}\\\" | python3 -c \\\"import sys,json; print(json.load(sys.stdin).get(\\\\\\\"opphubToken\\\\\\\",\\\\\\\"\\\\\\\"))\\\")\" | python3 -c \"\nimport sys, json\nd = json.load(sys.stdin)\nprint(f\\\"total entries: {d.get('total','?')}, returned: {len(d.get('data',[]))}\\\")\nfor e in d.get('data', [])[:8]:\n    print(' ', e.get('entryType','?'), e.get('entryDimension','?'), 'rawLen:', len(e.get('rawText','')))\n\"'\n```\n\n预期: 看到 ability entries 数量, 每个都有 rawText\n\n- [ ] **Step 6: 总结录入结果 (报告给用户)**\n\n说明: 录入了什么 entry, 各 entry 的 entryType/dimension/rawText 长度\n\n---\n\n## Task 7: 部署 ECS + 终验\n\n- [ ] **Step 1: scp web 改动**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev\nscp -r opphub-web/app/api/knowledge opphub-ecs:/opt/1panel/www/opphub-web/app/\nscp opphub-web/app/knowledge/page.tsx opphub-ecs:/opt/1panel/www/opphub-web/app/knowledge/page.tsx\nscp opphub-web/app/discover/page.tsx opphub-ecs:/opt/1panel/www/opphub-web/app/discover/page.tsx\n```\n\n- [ ] **Step 2: 容器 build + 重启**\n\n```bash\nssh opphub-ecs \"docker exec opphub-web sh -c 'cd /app && npm run build 2>&1 | tail -5'\"\nssh opphub-ecs \"docker restart opphub-web\"\nsleep 5\n```\n\n- [ ] **Step 3: 验证 6 项修复**\n\n```bash\n# 1. 编辑区高度\nssh opphub-ecs \"docker exec opphub-web sh -c 'cd /app && grep -c \\\"minHeight: 500\\\" .next/static/chunks/app/knowledge/page-*.js | head -3'\"\n# 预期: 1\n\n# 2. 历史展开\nssh opphub-ecs \"docker exec opphub-web sh -c 'cd /app && grep -c \\\"expandedId\\\" .next/static/chunks/app/knowledge/page-*.js | head -3'\"\n# 预期: 1\n\n# 3. 错误显示\nssh opphub-ecs \"docker exec opphub-web sh -c 'cd /app && grep -c \\\"setError\\\" .next/static/chunks/app/knowledge/page-*.js | head -3'\"\n# 预期: 1+\n\n# 4. 文案\nssh opphub-ecs \"curl -s http://localhost:3000/knowledge | grep -c '如何录入一条卡片' || echo 0\"\n# 预期: 1+ (在 bundle 内)\n\n# 5. /discover 视觉 (curl 看到绿色/蓝色 styles)\nssh opphub-ecs \"docker exec opphub-web sh -c 'cd /app && grep -c \\\"borderLeft\\\" .next/static/chunks/app/discover/page-*.js | head -3'\"\n# 预期: 1\n\n# 6. API 路由\nssh opphub-ecs \"curl -s -o /dev/null -w '%{http_code}\\n' http://localhost:3000/api/knowledge -H 'Authorization: Bearer test'\"\n# 预期: 401 (auth 失败但路由存在) 或 200\n```\n\n- [ ] **Step 4: git tag**\n\n```bash\ncd /Users/qiuxz/.openclaw/workspace-dev/opphub-web\ngit tag phase-4-bug-fixes\n```\n\n---\n\n## 完成判定\n\n- [ ] Task 0: API routes 重建 (route.ts / [id]/route.ts / [id]/history/route.ts)\n- [ ] Task 1: EditModal 高度修复\n- [ ] Task 2: 历史版本点行展开\n- [ ] Task 3: Delete 错误准确显示\n- [ ] Task 4: 文案改 \"如何录入一条卡片\"\n- [ ] Task 5: /discover 视觉区分\n- [ ] Task 6: skill 模拟 睿驰 录入\n- [ ] Task 7: ECS 部署 + 终验\n- git tag `phase-4-bug-fixes`\n\n## YAGNI 范围 (本阶段明确不做)\n\n- ❌ server 端加 `visibility=PUBLIC` 过滤\n- ❌ 撮合匹配对页面\n- ❌ 推送通道\n- ❌ 完整 SkillCard 组件回来\n- ❌ 重复 / 矛盾检测\n- ❌ 公司卡片信息编辑 (parsedFields 渲染)\n\nFile v5.0.0:docs/superpowers/plans/2026-07-23-phase5-v1-conflicts-radar.md\n\n# 阶段 5 V1: 冲突解决 + 能力雷达 + 公司画像聚合\n\n> **Agent 实施时按 subagent-driven-development 走 task-by-task**\n\n---\n\n## 0. 边界\n\n**已做 (commit 在 main)**: DB `parsedFields Json?` + `lib/conflicts.ts` + GET API 返 `computeCurrentFields(parsedFields)` + PATCH 接受 `userOverrides`.\n\n**V1 做**: skill 拼 _sources / recharts 雷达 / /knowledge 重写 / /discover 重写含撮合触发 + 详情 modal\n\n**不做**: 时间衰减 / 置信度加权 / HR-CRM 自动同步 / 撮合→交易 / PDF 解析 / 推送 / PUBLIC 过滤 / sources 时间线\n\n---\n\n## 1. 数据契约 (V1)\n\n### `parsedFields` (V1 单 source 简化)\n```js\n{\n  // 顶层 = 用户可见当前值 (computeCurrentFields 实时算)\n  companyName, industry:{code,name}, legalPerson, registeredCapital,\n  creditCode, teamSize, address, city, businessDescription,\n  _sources: {\n    [field]: {\n      current: <value>,\n      userOverride: null | <value>,         // null = 没仲裁\n      userOverrideAt: null | ISO,\n      candidates: [\n        { value, source: \"im:msg-uuid\", sourceType: \"im\", ingestedAt: ISO }\n      ]\n    }\n  }\n}\n```\n\nV1 算法: current = userOverride 优先, 否则 candidates 里 ingestedAt 最新. 无时间衰减无 confidence.\n\n### `opc_matching` 表\n```\nfromOpcId, toOpcId, fromEntryId, toEntryId, matchScore, status (pending/active/declined/closed), createdAt, updatedAt\n```\n\n---\n\n## 2. Tasks\n\n| # | Files | Action |\n|---|---|---|\n| 1 | skill knowledge-card | extractParsedFields 加 _sources |\n| 2 | skill knowledge-submit | 确认传 parsedFields (无改动, 验证) |\n| 3 | opphub-web package.json + _components/CapabilityRadar.tsx | 装 recharts, 写雷达组件 (5-8 维, fill 100%) |\n| 3.5 | prisma/schema.prisma + api/matchings/route.ts + api/matchings/[id]/route.ts | 加表 + POST + GET |\n| 4 | app/knowledge/page.tsx | 重写: 头部画像 + 雷达 + 4 类子卡网格 |\n| 5 | app/discover/page.tsx | 重写: 雷达缩略 + 关联理由 + 「进入撮合」CTA (POST + 结果 modal) + 「看完整画像」CTA (详情 modal) |\n| 6 | scp + db push + build + restart | Deploy |\n\n---\n\n## Task 1: skill knowledge-card 输出 _sources\n\n**改**: `extractParsedFields(name, rawText, industry)` 同时输出顶层字段 + `_sources`\n\n- 每个顶层字段 (companyName, industry, legalPerson, registeredCapital, creditCode, teamSize, address, city, businessDescription) 都配 1 个 candidate (sourceType=\"im\", source=\"im:skill-extract:\" + ISO 时间戳, ingestedAt=ISO)\n- 顶层字段名 + _sources key 保持一致 (避免 computeCurrentFields 跳过)\n\n**Verify**: 跑 node bin/opphub-knowledge-card --name \"X\" --raw-text \"...\" --json, parsedFields._sources 应含 9 个 keys (含 companyName/industry 等), 每个 candidates 长度 1\n\n**Commit**: `feat(skill): knowledge-card parsedFields 加 _sources 多源结构`\n\n---\n\n## Task 2: skill knowledge-submit 持续传 parsedFields\n\n**Verify**: 当前 body 已含 `parsedFields: parsedFields ?? undefined` — 无需改动, 验证仍存在\n\n**可能 commit**: `chore(skill): 确认 submit 透传 parsedFields` (如果发现需要修, 不需要则 no commit)\n\n---\n\n## Task 3: recharts + CapabilityRadar\n\n**装**: `npm install recharts --save`\n\n**New file**: `app/_components/CapabilityRadar.tsx`\n- Props: `data: [{name, level}]`, `size`, `height`\n- Recharts: `RadarChart > PolarGrid, PolarAngleAxis, PolarRadiusAxis (domain 0-1), Radar (dataKey=level, fill=#4f46e5, opacity=0.4)`\n- 默认 size=320 height=320\n\n**Commit**: `feat(ui): 装 recharts + CapabilityRadar 组件`\n\n---\n\n## Task 3.5: opc_matching 表 + POST/GET /api/matchings\n\n**Schema**: prisma/schema.prisma 加 `model OpcMatching` (见 §1)\n- `OpcAccount` 加两个 relation: `matchingsFrom OpcMatching[] @relation(\"MatchFrom\")` + `matchingsTo OpcMatching[] @relation(\"MatchTo\")`\n\n**db push**: 在容器内 `DATABASE_URL=... npx prisma db push --skip-generate --accept-data-loss`\n\n**Create**: `app/api/matchings/route.ts`\n- POST: 接收 `{ otherEntryId, matchScore }`, 鉴权 (JWT) → 查 otherEntry → 校验 not self → 检查去重 (same fromOpc+toOpc status=pending 已存在则复用) → `prisma.opcMatching.create({status:\"pending\"})` → 返 `{ok, matchingId, status}`\n- GET: 接受 `?role=from|to|both` → 列出我的 matchings\n\n**Commit**: `feat(api): opc_matching 表 + POST /api/matchings (撮合触发 + V1 推送占位)`\n\n---\n\n## Task 4: /knowledge 重写\n\n**Replace**: `app/knowledge/page.tsx` 整文件重写, 重点:\n- `Suspense` wrap + `KnowledgeInner` 子组件 (避免 useSearchParams 警告)\n- fetch `/api/knowledge?opcId=me`, `entries: EntryShape[]`\n- 取第一条 ability/downstream entry 的 parsedFields 做 profile (companyName/industry/teamSize/city/legalPerson/registeredCapital/businessDescription)\n- 数据不分组, 4 类子卡用 `grouped = useMemo(...)` 归集, `TAB_ORDER = ['ability','downstream','upstream','peer']`\n- 能力雷达: 取 grouped.ability.slice(0, 8).map(e => ({name: e.entryDimension, level: 0.7})) — V1 固定 0.7\n- Header card: 公司名 + 元信息 + 业务描述\n- 4 类子卡网格: 每类独立 section, 卡片展示 entryDimension\n- 卡片点击跳 `/knowledge/${e.id}` (DetailModal 留作后续)\n- 不再做 modal (详情/编辑/删除) — V1 只搭页面结构, modal 留作后续\n- 空状态: 提示去 IM @偶合 录入\n\n**Commit**: `feat(knowledge): /knowledge 重写为公司画像 + 能力雷达 + 4 类子卡网格`\n\n---\n\n## Task 5: /discover 重写\n\n**Replace**: `app/discover/page.tsx` 整文件\n\n**核心**:\n- `Suspense` wrap + `DiscoverInner`\n- 读 sp `role` (buyer/seller, default buyer)\n- 我的 type: `role==='buyer' ? downstream : ability`\n- 候选 type: `role==='buyer' ? ability : downstream` (V1: 候选来自自己的另一个 type, 同 OPC 内, 因为 PUBLIC 还没实装)\n- `calcMatchScore(myType, otherType, ingestedAt)`:\n  - ability↔downstream: 0.9\n  - 其他: 0.5-0.7\n  - 7d 内加权 +0.1\n- 渲染每张卡片: 公司名 + 关联度 badge + 雷达缩略 (每 entry 的 _sources 字段名, level 0.7) + 关联理由 + 双 CTA\n- CTA「进入撮合」: `startMatching(entry)` — fetch POST /api/matchings, 弹 MatchingResultModal\n- CTA「看完整画像」: `setDetailEntry(entry)` — 弹 DetailModal\n\n**Modal 组件** (放在 page.tsx 末尾):\n- `DetailModal`: 弹窗内显示公司基本信息 + 业务描述 + 能力雷达 + rawText (折叠 details)\n- `MatchingResultModal`: 显示「撮合已发起, 待对方回应」状态, success 时含绿色提示「撮合中心/IM/出价/订单 将在下阶段实装」\n\n**states**: detailEntry, matchingResult, related state setters\n\n**Commit**: `feat(discover): 重写 - 雷达缩略 + 关联理由 + 撮合 CTA + 详情 modal`\n\n---\n\n## Task 6: Deploy + 烟测\n\n**scp**: package.json, package-lock.json, prisma/schema.prisma, app/_components/CapabilityRadar.tsx, app/knowledge/page.tsx, app/api/matchings/**, app/discover/page.tsx\n\n**Container**:\n```bash\ndocker exec opphub-web sh -c \"cd /app && npm install recharts --save 2>&1 | tail -3\"\ndocker exec opphub-web sh -c \"cd /app && DATABASE_URL=... npx prisma db push --skip-generate --accept-data-loss 2>&1 | tail -3\"\ndocker exec opphub-web sh -c \"cd /app && rm -rf .next && npm run build 2>&1 | tail -5\"\ndocker restart opphub-web\n```\n\n**烟测**:\n- /knowledge 200\n- /discover 200\n- 跑 skill 录入上海睿驰嘉禾 → 数据库 parsedFields._sources 写入\n- POST /api/matchings 在 curl 测能创建\n\n**Tag**: `phase-5-v1-conflicts-radar`\n\n---\n\n## 流程图\n\n```\n/discover → 看卡片 → 点「💬 进入撮合」 \n  → POST /api/matchings (V1 推送占位) \n  → 弹 MatchingResultModal (撮合已发起, 待对方回应)\n  → 提示下阶段\n\n点「👀 看完整画像」 \n  → 弹 DetailModal (公司画像 + 雷达 + rawText)\n```","readmeExcerpt":"Skill: opphub Owner: mtty-ai Summary: 偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。 Tags: latest:5.0.0 Version history: v5.0.0 | 2026-07-27T08:56:48.339Z | user OPC → User 全量重命名: /api/opc/ → /api/user/, token 字段 opc_id → user_id. check-update.js 加 stripPrefix 函数 (v4.0.1 漏定义, 修 plugin cron 'skill 没查到' 错误) Archive index: Archive v5.0.0: 49 files, 223936 bytes Files: _meta.json (125b), bin/opphub (7499b), bin/opphub-check-","codeSnippets":[],"executableExamples":[{"language":"bash","snippet":"openclaw skills install @mtty-ai/opphub\nopenclaw plugins install clawhub:@mtty-ai/opphub"},{"language":"bash","snippet":"opphub plugin-check --json"},{"language":"bash","snippet":"openclaw plugins uninstall clawhub:@mtty-ai/opphub\nopenclaw skills uninstall @mtty-ai/opphub"},{"language":"text","snippet":"┌─────────────────────────────────────────────────────┐\n│  Skill / Plugin 层                                  │\n│  发 \"原始内容\" (IntentMessage, channel-agnostic)   │\n│  - prompt / options / actions / inputHint / links   │\n└──────────────────┬──────────────────────────────────┘\n                   ↓\n┌─────────────────────────────────────────────────────┐\n│  OpenClaw runtime · Content Renderer 层 ★ 这里 ★    │\n│  每个 channel 一个 renderer:                         │\n│  · FeishuCardRenderer   (interactive card + actions) │\n│  · FeishuGroupRenderer  (同上,群 channel)            │\n│  · WeixinRenderer       (文本 + emoji 协议)          │\n│  · SlackRenderer        (Block Kit)                  │\n│  · DiscordRenderer      (Embed + Components)         │\n│  · TelegramRenderer     (InlineKeyboard)             │\n│  · CliRenderer          (inquirer.select / confirm)  │\n│  · WebRenderer          (HTML form, 走 opphub-web)   │\n│  · FallbackTextRenderer (纯文本兜底)                │\n└──────────────────┬──────────────────────────────────┘\n                   ↓\n                Channel (IM / CLI / Web)"},{"language":"ts","snippet":"interface IntentMessage {\n  prompt: string;                    // 主文案 (\"选默认推送通道\")\n  options?: Array<{                  // 选项 (pickOne 场景)\n    id: string;                      // 选中时回传 (feishu:pm)\n    label: string;                   // 显示 (\"feishu:pm ⭐\")\n    hint?: string;                    // 副文案 (\"pm bot\")\n    isDefault?: boolean;\n  }>;\n  actions?: Array<{                  // 按钮\n    id: string;                      // 唯一 action id\n    label: string;                   // 按钮文字 (\"确认\" / \"🚀 一键装 plugin\")\n    style?: 'primary' | 'default' | 'danger';\n    payload?: Record<string, any>;   // action 回调带的元数据\n  }>;\n  inputHint?: string;                // 自由文本输入\n  links?: Array<{                    // 链接按钮 (install 引导)\n    text: string;\n    url: string;\n  }>;\n  markdown?: string;                 // 富文本 (lark_md / mrkdwn / html)\n  header?: {\n    title: string;\n    color?: 'blue' | 'orange' | 'red' | 'green';\n  };\n  metadata?: Record<string, any>;\n}"},{"language":"ts","snippet":"declare global {\n  interface SkillApi {\n    /** 单向通知 (无操作) */\n    send(intent: IntentMessage): Promise<void>;\n\n    /** 交互意图 - 等用户回复 (pickOne / actions) */\n    askInteractive(intent: IntentMessage & {\n      options?: Option[];\n      actions?: Action[];\n    }): Promise<UserReply>;\n\n    /** 自由文本输入 */\n    askFreeText(intent: IntentMessage & {\n      inputHint: string;\n    }): Promise<string>;\n\n    /** 当前 channel hint */\n    getChannel(): {\n      type: 'feishu' | 'weixin' | 'slack' | 'discord' | 'telegram' | 'cli' | 'web';\n      account?: string;\n      supportsCards: boolean;\n      supportsInlineActions: boolean;\n      supportsFreeText: boolean;\n    };\n  }\n}"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: opphub\nversion: 5.0.0\ndescription: 偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。\nauthor: mtty-ai\nhomepage: https://github.com/mtty-ai/opphub-skill\nentry: bin/opphub\ndefaultLocale: zh-CN\nrequires:\n  bins:\n    - node>=18\n    - openclaw\n    - jq\n    - curl\n    - security\n    - openssl\n    - xdg-open\n  env: []\n  platform:\n    darwin: full\n    linux: full\n    windows: none\nmetadata:\n  api: https://api.opphub.ruiplus.cn\n  deviceFlow:\n    authorize: https://api.opphub.ruiplus.cn/api/oauth/device/code\n    token: https://api.opphub.ruiplus.cn/api/oauth/device/token\n    userinfo: https://api.opphub.ruiplus.cn/api/oauth/userinfo\n  refreshToken:\n    endpoint: https://api.opphub.ruiplus.cn/api/oauth/token\n  client_id: opphub-plugin\n  scope: profile ws:read ws:write\n---\n\n# 偶合 OppHub\n\n把偶合账号接入 OpenClaw，录入公司、查商机、收推送。\n\n支持 3 种触发方式：\n- IM 群聊 / 私聊 @bot（飞书 / 微信 / 钉钉 / Telegram 等）\n- OpenClaw 后台直接调用\n- cron 定时任务\n\n具体 IM 取决于 OpenClaw runtime 已装的 channel plugin。\n\n---\n\n## 一句话介绍\n\n录入公司、查商机、收推送，3 件事 bot 都替你跑。\n\n---\n\n## 安装\n\n```bash\nopenclaw skills install @mtty-ai/opphub\nopenclaw plugins install clawhub:@mtty-ai/opphub\n```\n\n两条都要装：\n\n- `opphub` skill：在 IM 里跟 bot 对话\n- `opphub` plugin：装好后 bot 收得到推送，登录态写 macOS Keychain\n\n装完自动重启 gateway。\n\n依赖 runtime 自动校验，不缺。\n\n---\n\n## 五步上手\n\n### 1. 注册偶合账号\n\n去 <https://api.opphub.ruiplus.cn/register>，邮箱/手机号 + 6 位验证码。\n\n### 2. 验证 plugin 装好\n\n```bash\nopphub plugin-check --json\n```\n\n返 `installed: true` 就 OK。没装好跑 `openclaw plugins install clawhub:@mtty-ai/opphub` 重装。\n\n### 3. @bot 说\"偶合登录\"\n\nbot 走 OAuth device flow：\n- bot 私聊发你激活链接（不会在群里贴验证码）\n- 你打开链接点同意\n- 登录态自动写 Keychain，下次不用再登\n\n### 4. @bot 说\"偶合配置\"\n\nbot 列出你本机已装的 IM 通道（飞书/微信/钉钉/Telegram 等），你选一个作**默认推送通道**。后续商机推送、cron 提醒都走这个通道。\n\n跳过这步也行——bot 会用 server 端上次选的通道；不放心就先跑一次确认。\n\n### 5. @bot 说\"偶合录入 [公司名]\"\n\nbot 自动 6 步跑完，只问你一次\"是否入库\"：\n\n- 联网查工商 / 业务 / 招聘 / 项目\n- 推断行业（MCN / SaaS / 律所 / 制造）\n- 拆能力卡片 + 上下游\n- 给你 1 张清单确认\n- 批量入库（幂等，重复跑不冲突）\n- 跑上下游匹配，列结果\n\n> 第 4 步拆出能力后, bot **不直接入库**, 先给一张确认清单 (含 4 种 entryType: 我能提供/我想找/我的依赖/同行关系).\n> 用户回复:\n> - 「确认」 → bot 调 `knowledge-submit --confirm` 入库\n> - 「删 <type.dimension>」 → bot 去掉某条重发清单\n> - 「重抽」 → 回到阶段 1 重新联网查\n> - 「改 <字段>」 → 改解析字段后重发清单\n\n---\n\n## 常用命令\n\n| 触发方式 | bot 做的 |\n|---|---|\n| 偶合登录 | OAuth 登录 |\n| 偶合状态 | 看登录态 + 推送通道 + 知识库 |\n| 偶合配置 | 选默认推送通道 |\n| 偶合录入 [公司] | 录入公司能力画像 |\n| 偶合录入关联公司 + 合同 xls | 解析合同拆上下游 |\n\n**触发方式**：群里 @bot、私聊 bot、OpenClaw 后台调用、cron 定时都可以。\n\n---\n\n## 常见问题\n\n**没收到推送？**\n确认 plugin 装好：`opphub plugin-check --json`。没装就跑 `openclaw plugins install clawhub:@mtty-ai/opphub`。\n\n**token 过期了？**\n@bot 说\"偶合登录\"重新走一遍。\n\n**浏览器没弹？**\n复制 bot 私聊里的链接手动打开。\n\n**录入撞同行业了？**\nbot 会弹卡片让你选行业。\n\n**录入冲突了？**\nbot 会列新旧版本让你选保留哪个。\n\n---\n\n## 卸载\n\n```bash\nopenclaw plugins uninstall clawhub:@mtty-ai/opphub\nopenclaw skills uninstall @mtty-ai/opphub\n```\n\nKeychain 里的登录态要手动清：macOS Keychain 搜 `opphub:default` 删除。\n\n---\n\n## 反馈\n\nissue: https://github.com/mtty-ai/opphub-skill/issues"},{"path":"README.md","content":"# 偶合 OppHub · OpenClaw Skill\n\nOpenClaw bot skill · device flow 登录 · 与 opphub-plugin 共 Keychain\n\n详见 SKILL.md (本仓 bot skill 模式说明, 由 OpenClaw 加载)"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn7ethqpnt6p4vba8zp33jx2hh89vmkc\",\n  \"slug\": \"opphub\",\n  \"version\": \"5.0.0\",\n  \"publishedAt\": 1785142608339\n}"},{"path":"CHANGELOG.md","content":"# Changelog\n\n公开版仅保留用户可感知的功能变更。内部迭代历史不入仓。\n\n## v5.0.0 (2026-07-24) ⚠️ BREAKING\n\n### 加 (New)\n- **架构重写**：skill 不再写死任何业务词 / 行业模板 / 平台字典\n  - 删除 `INDUSTRY_TEMPLATES` (mcn/saas/law/mfg 4 个行业的所有 abilities/upstream/downstream/peer 列表)\n  - 删除 `INDUSTRY_SIGNALS` (token 频率猜行业)\n  - 删除 `PLATFORM_TERMS` / `GENERIC_TERMS` / `TAIL_GENERIC` / `MID_GENERIC` (所有 stop word 词表)\n  - skill 现在认任何中文公司 rawText：律所 / 餐饮 / 制造 / 数字营销 / … 都用同一套代码\n- **LLM 任务包**：每张卡 + JSON 输出都含\n  - `evidenceCandidates` (string[30]): n-gram 频次排名 + 长度加分\n  - `evidenceAnswerPrompt` (string): 单卡 LLM 选择规则 + JSON 输出格式\n  - `llmInstruction` (string): 顶层 LLM 一次性任务流 (read → pick → rewrite → submit)\n  - 占位符 `(证据词: <待 LLM 填写>)` 在 rawText 里等 LLM 填\n- **heading 驱动拆卡**:\n  - rawText `## 业务描述` 节下 `### 维度名` 直接生成 ability 卡片\n  - `### 上游依赖 / 下游客户 / 同业联盟` 子标题自动判定 type (无需模板)\n  - 其它行业 (律所/餐饮等) 写啥拆啥: `### 公司诉讼` / `### 火锅堂食` 都能识别\n\n### 改 (Changed)\n- 数据流从 \"skill 输出 → 用户确认 → LLM 再审 → submit\" 多步，简化为 \"skill 输出 LLM 任务包 → LLM 一次性完成 → submit\"\n- 无行业推断 (token 频率猜), 行业归属由 LLM 在对话里判断并传入\n\n### 弃用 (Deprecated)\n- 任何用户写死的维度词表 (e.g. 老 v4.x 模板里硬编码的\"达人营销\"/\"短视频内容制作\")\n\n### 测 (Test)\n- 11 个单元测试: 通用性守卫 (源码不许有业务词) / heading 拆 / n-gram 候选 / 占位符 / LLM 提示\n- 用 `npm test` 跑\n\n## v4.0.10 (2026-07-24)\n\n### 加 (New)\n- **knowledge-card evidence 提取加固** (extractEvidenceFromDesc):\n  - 长度上限 6→8, 让 \"短视频内容制作\"/\"店铺代运营\" 等 7-8 字短语能拆出\n  - 新增 PLATFORM_TERMS 优先扫 (抖音/快手/小红书/B站/视频号 等 14 平台)\n  - 解决 \"精选小红书博主合作\" 短语被 6 字限制吞掉的 case, 拆出关键词 \"小红书\"\n  - 尾部泛词剥离 (等等/行业/领域/场景/赛道/玩法/业务/服务/平台/资源)\n  - 列表前缀剥离 (包括/比如/例如/以及)\n  - 人称前缀剥除 (我们/你/他/她/它)\n\n### 测 (Test)\n- 单元测试 9 → 12 (新增 3 个边缘 case 覆盖人称/列表前缀/跨公司对比)\n- `npm test` 全部 12 测试通过 (~600ms)\n\n## v4.0.9 (2026-07-24)\n\n### 修 (Fixed)\n- **knowledge-card 证据词提取**: 之前用模板写死的 `purpose` 字符串当证据词源, 不同公司卡片雷达词趋同 (e.g. 都显示 \"KOL 投放\"/\"媒介代理\")。现在从 rawText 自然语言描述里挖真实关键词 (拆标点 + 滤停用前缀 + 限 2-6 字名词性片段), 两家公司 evidence 完全独立。\n- **证据词格式统一**: 修复 `buildCard()` 漏传 `evidenceList` 参数 bug。所有卡片现在统一输出 `(证据词: kw1, kw2, ...)` 格式, 去掉旧 `(证据: rawText 包含 \"${dim}\")` 兜底文案 (前端 regex 解析不到)。\n- **雷达不再有杂质**: 前端雷达原显示 \"蓝色光标\"/\"宝马等\"等公司名/团队背景, 现在都是真实能力词 (达人筛选/店铺搭建/活动策划等)。\n- **extractParsedFields 城市检测**: 不再被同业联盟里 `无忧传媒(北京...)` 这类字符串污染。限定到「地址字段 || 工商信息节」内查找城市。\n- **knowledge-discover 骨架校验**: 加 `SKELETON_PATTERNS` 检测, rawText 含 `(名称 / 法人 / 注册资本...)` 等填空内容时直接拒绝 (skeleton_unfilled), 强制 LLM 用 web_search 搜真实数据。\n- **knowledge-discover 搜索指令内嵌**: `--name` 模式输出 `_explicitInstructions` 字段, 列出每节搜索命令 + 禁止传空骨架警告。\n\n### 加 (New)\n- 9 个单元测试覆盖 `extractEvidenceFromDesc` / `extractDimDesc` / `extractParsedFields` 三大核心逻辑。用 `npm test` 跑 (`tests/unit-knowledge-card.js`)。\n\n### 测 (Test)\n- `node --test tests/unit-knowledge-card.js` 全部 9 测试通过 (~450ms)\n- `npm test` 端到端验证脚本 (`tests/e2e-verify.js`) 不动\n\n## v4.0.0 (2026-07-22)\n\n### 加 (New)\n\n- OAuth Device Flow 登录 + token 自动写 macOS Keychain / Linux AES-256-GCM 加密文件\n- 6 步闭环引导: 注册 → 登录 → 装 plugin → 配默认通道 → 知识库 → 商机匹配\n- 开放式知识库录入: discover (生成 rawText 骨架) + card (按行业拆能力卡片) + submit (idempotent 入库 + 冲突检测)\n- 关联公司录入: relate (xls/html 合同解析, 拆 top 客户 / 供应商 / 类别聚合)\n- bot turn 调用 LLM 工具 (web_sear"},{"path":"docs/runtime-channel-renderer-v31-design.md","content":"# opphub-skill v3.1 · OpenClaw runtime · channel-renderer 渲染层架构\n\n> \"原始内容归原始内容, 你得 写一个 飞书 card 的渲染层 当然未来也有其他通道需要特定的渲染\"\n\n> **含义**: runtime 渲染层是 OpenClaw 平台活, **不在 opphub skill v3.1 范围**\n> **本文状态**: 独立存档, 等 OpenClaw runtime 团队接\n\n---\n\n## 0. 文档归属\n\n| 项 | 归属 |\n|---|---|\n| **本档**(运行时渲染层) | OpenClaw runtime 团队 |\n| opphub skill v3.1 doc | skill 层 (我开干的范围) |\n| opphub-server schema v3.1 | server 层 (单独存档) |\n\n---\n\n\n> \"原始内容归原始内容, 你得 写一个 飞书 card 的渲染层 当然未来也有其他通道需要特定的渲染\"\n\n\n> \"只到 skill 开放完, 没开发完, 不会会话\"\n\n**含义**:\n- skill 层只发 \"IntentMessage 原始内容\"(channel-agnostic)\n- skill 层不拼飞书 card / Block Kit / InlineKeyboard\n- runtime 渲染层是 OpenClaw 平台活, **不在 opphub skill v3.1 范围**\n- server schema (`OpcKnowledgeEntry` 表) 也是单独一条线\n\n---\n\n\n| # | 任务 | 改动层 | 工时 | 依赖 |\n|---|---|---|---|---|\n| N10 | Skill 改造: 5 个 bin 都改用 `bot.skillApi.askInteractive/askFreeText/send`, 删硬编码飞书 JSON | Skill | 3h | 无 |\n| N11 | SKILL.md frontmatter: `requires.tools` 加 `minimax__web_search / web_fetch / minimax__understand_image / pdf / memory_search / wiki_search` | Skill | 0.5h | 无 |\n| N12 | OpenClaw runtime 新 API: `skillApi.send/askInteractive/askFreeText/getChannel` + 类型定义 + 8 个 Renderer (FeishuCard / FeishuGroup / WeixinText / SlackBlock / DiscordEmbed / TelegramInline / CliInquirer / WebForm) + FallbackText | Runtime | 8h | 无 (这是 OpenClaw runtime 改造, 不是 skill 改造) |\n| N13 | E2E 多 channel 测试: 飞书 DM / 飞书群 / 微信 iLink / CLI 各跑一遍偶合配置 + 偶合画像 6 步 | 全链路 | 2h | N10 N12 |\n| **N10-N13 合计** | | | **13.5h ≈ 1.7 天** | |\n\n**总工时** v3.1: 19.5h (skill 6 步) + 13.5h (renderer) + 1h (server schema N4-N5) + 1h (server API N5) = **约 35h ≈ 4.5 天**\n\n\n---\n\n\n\n### 关键分工\n\n```\n┌─────────────────────────────────────────────────────┐\n│  Skill / Plugin 层                                  │\n│  发 \"原始内容\" (IntentMessage, channel-agnostic)   │\n│  - prompt / options / actions / inputHint / links   │\n└──────────────────┬──────────────────────────────────┘\n                   ↓\n┌─────────────────────────────────────────────────────┐\n│  OpenClaw runtime · Content Renderer 层 ★ 这里 ★    │\n│  每个 channel 一个 renderer:                         │\n│  · FeishuCardRenderer   (interactive card + actions) │\n│  · FeishuGroupRenderer  (同上,群 channel)            │\n│  · WeixinRenderer       (文本 + emoji 协议)          │\n│  · SlackRenderer        (Block Kit)                  │\n│  · DiscordRenderer      (Embed + Components)         │\n│  · TelegramRenderer     (InlineKeyboard)             │\n│  · CliRenderer          (inquirer.select / confirm)  │\n│  · WebRenderer          (HTML form, 走 opphub-web)   │\n│  · FallbackTextRenderer (纯文本兜底)                │\n└──────────────────┬──────────────────────────────────┘\n                   ↓\n                Channel (IM / CLI / Web)\n```\n\n### Skill / Plugin API (IntentMessage 原始内容)\n\n```ts\ninterface IntentMessage {\n  prompt: string;                    // 主文案 (\"选默认推送通道\")\n  options?: Array<{                  // 选项 (pickOne 场景)\n    id: string;                      // 选中时回传 (feishu:pm)\n    label: string;                   // 显示 (\"feishu:pm ⭐\")\n    "}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":"偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。 Skill: opphub Owner: mtty-ai Summary: 偶合 OppHub - 把偶合账号接入 OpenClaw，录入公司、查商机、收推送。 Tags: latest:5.0.0 Version history: v5.0.0 | 2026-07-27T08:56:48.339Z | user OPC → User 全量重命名: /api/opc/ → /api/user/, token 字段 opc_id → user_id. check-update.js 加 stripPrefix 函数 (v4.0.1 漏定义, 修 plugin cron 'skill 没查到' 错误) Archive index: Archive v5.0.0: 49 files, 223936 bytes Files: _meta.json (125b), bin/opphub (7499b), bin/opphub-check-","editorialQuality":{"score":100,"threshold":65,"status":"ready","wordCount":938,"uniquenessScore":54,"reasons":[]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-10T04:28:01.898Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-10T04:28:01.898Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-10T08:44:56.657Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}