diff --git a/docs/designs/issue-385-default-responder.md b/docs/designs/issue-385-default-responder.md new file mode 100644 index 000000000..ed8c6f13b --- /dev/null +++ b/docs/designs/issue-385-default-responder.md @@ -0,0 +1,70 @@ +# Issue #385: Configurable Default Responder for New Threads + +Parent: F127 (Member Overview Refactor) + +## Problem + +The "global default responder" for new threads (no history, no @mention, no preferredCats) is currently derived from `breeds[0].defaultVariantId`, hardcoded to resolve to `opus`. This couples the default to breed order rather than an explicit runtime-configurable choice. + +## Design + +### Member Overview Card (Proposed) + +- Default responder cat gets a `star + "默认回复"` orange badge next to its name +- Default cat card border: 2px `#D49266` (vs 1px `#F1E7DF` for non-default) +- Non-default cats: unchanged appearance + +### Member Detail Panel (New Toggle) + +- **Label**: "全局默认回复猫" +- **Description**: "仅影响新会话无历史时第一条消息,不影响已有 thread 的续接逻辑" +- **Toggle**: standard on/off +- **Info box** (when toggle is off and another cat is default): "当前默认: {name}({catId})。设为此成员后,{name}的默认状态将自动取消。若此成员不可用(available=false),将退回 breed 默认。" + +### Visual reference + +Pencil mockup: `/pencil-new.pen` (3 frames: Current State, Proposed State, Member Detail Panel) + +## Technical Plan + +### Storage + +- Add `defaultResponderCatId?: CatId` at the **catalog top level** (CatCafeConfig) +- NOT a boolean on each RosterEntry (avoids mutual exclusion complexity) +- Single atomic write: set B = clear A implicitly + +### New Resolver + +- Create `getDefaultResponderCatId()` — dedicated to "new thread no-history" semantics +- Keep existing `getDefaultCatId()` unchanged — reviewer fallback etc. stay as-is +- Fallback chain: `defaultResponderCatId` (roster) -> `breeds[0].defaultVariantId` (breed) -> `'opus'` (hardcoded) + +### Affected Call Sites + +| Location | Current | Change | +|----------|---------|--------| +| `AgentRouter.ts` final fallback | `getDefaultCatId()` | `getDefaultResponderCatId()` | +| `ConnectorRouter.ts:277,373` | frozen `'opus'` | dynamic `getDefaultResponderCatId()` | +| `index.ts:1973` | `defaultCatId: 'opus'` | dynamic read | +| `ChatContainer.tsx:486` | `targetCats[0] \|\| 'opus'` | fetch from config | +| `reviewer-matcher.ts:72` | `getDefaultCatId()` | **NO CHANGE** (stays as-is) | + +### API + +- `PATCH /api/config` with `{ key: 'defaultResponderCatId', value: catId }` — reuses existing config patch endpoint +- Uniqueness enforced: single value, not per-member boolean + +### Verification Checklist + +- [x] Hub member overview: switch default -> refresh -> still shows correct member +- [x] New thread / no history / no @mention -> routes to configured default +- [x] Connector new external thread -> uses configured default, not frozen 'opus' +- [x] Default member unavailable (available=false) -> falls back to breed default +- [x] Reviewer fallback semantics NOT changed + +## Scope Exclusions + +- preferredCats semantics: unchanged +- Last-replier logic for existing threads: unchanged +- routingPolicy: unchanged +- Reviewer fallback: unchanged (stays on `getDefaultCatId()`) diff --git a/docs/designs/pencil-new.pen b/docs/designs/pencil-new.pen new file mode 100644 index 000000000..bef66e5ae --- /dev/null +++ b/docs/designs/pencil-new.pen @@ -0,0 +1,1081 @@ +{ + "version": "2.10", + "children": [ + { + "type": "frame", + "id": "OiXv6", + "x": 0, + "y": 0, + "name": "Current — 成员总览", + "width": 380, + "fill": "#F9F6F3", + "cornerRadius": 16, + "layout": "vertical", + "gap": 12, + "padding": [ + 24, + 20 + ], + "children": [ + { + "type": "text", + "id": "jlzbf", + "name": "curTitle", + "fill": "#8A776B", + "content": "CURRENT STATE", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "700", + "letterSpacing": 2 + }, + { + "type": "frame", + "id": "l8R7f", + "name": "curToolbar", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "SDSVe", + "name": "curFilter", + "fill": "#8F8075", + "content": "全部 · 订阅 · API Key · 未启用", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ZnnWn", + "name": "curAddBtn", + "fill": "#D49266", + "cornerRadius": 9999, + "padding": [ + 8, + 16 + ], + "children": [ + { + "type": "text", + "id": "R2wJg", + "name": "curAddLbl", + "fill": "#FFFFFF", + "content": "+ 添加成员", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "KF87x", + "name": "coCard", + "width": "fill_container", + "fill": "#FFF8F0", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#D4A76A" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "8so10", + "name": "coRow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fi01k", + "name": "coLeft", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "ZULwt", + "name": "coAvatar", + "fill": "#D4A76A", + "width": 32, + "height": 32 + }, + { + "type": "text", + "id": "VRSyS", + "name": "coName", + "fill": "#2D2118", + "content": "铲屎官", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "V1kQ5", + "name": "ownerBadge", + "fill": "#FFF3E0", + "cornerRadius": 9999, + "gap": 4, + "padding": [ + 4, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "r1Zrw", + "name": "ownerIcon", + "width": 12, + "height": 12, + "iconFontName": "lock", + "iconFontFamily": "lucide", + "fill": "#E65100" + }, + { + "type": "text", + "id": "6Z3H7", + "name": "ownerLbl", + "fill": "#E65100", + "content": "Owner", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "clfXo", + "name": "coMeta", + "fill": "#8A776B", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "别名: 铲屎官 · 只能编辑,不能新增或删除", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "1gJVK", + "name": "coMention", + "fill": "#D4A76A", + "content": "@co-creator @铲屎官", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "D1qTK", + "name": "opusCard", + "width": "fill_container", + "fill": "#FFFDFC", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1E7DF" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "13xRJ", + "name": "opusRow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rj98s", + "name": "opusName", + "fill": "#2D2118", + "content": "布偶猫 · 宪宪", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "Lfkbl", + "name": "opusBadge", + "fill": "#E8F5E9", + "cornerRadius": 9999, + "padding": [ + 4, + 10 + ], + "children": [ + { + "type": "text", + "id": "Yf4Y3", + "name": "opusBadgeLbl", + "fill": "#4CAF50", + "content": "已启用", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "XolBG", + "name": "opusMeta", + "fill": "#8A776B", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Anthropic · claude-opus-4-6 · 内置 OAuth 账号", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "7Nyk6", + "name": "opusMention", + "fill": "#9D7BC7", + "content": "@opus @宪宪 @布偶猫", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "A0K32", + "name": "sonnetCard", + "width": "fill_container", + "fill": "#FFFDFC", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1E7DF" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "lyhRi", + "name": "sonnetRow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "VHYbt", + "name": "sonnetName", + "fill": "#2D2118", + "content": "布偶猫 · 克拉", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "jCbHT", + "name": "sonnetBadge", + "fill": "#E8F5E9", + "cornerRadius": 9999, + "padding": [ + 4, + 10 + ], + "children": [ + { + "type": "text", + "id": "OiTqM", + "name": "sonnetLbl", + "fill": "#4CAF50", + "content": "已启用", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "eCF1B", + "name": "sonnetMeta", + "fill": "#8A776B", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Anthropic · claude-sonnet-4-6 · 内置 OAuth 账号", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KGzzZ", + "name": "sonnetMention", + "fill": "#9D7BC7", + "content": "@sonnet @克拉", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "fPnC2", + "name": "curHint", + "fill": "#B59A88", + "content": "点击任意卡片进入成员配置 →", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "pO7Qz", + "x": 420, + "y": 0, + "name": "Proposed — 默认回复猫", + "width": 380, + "fill": "#F9F6F3", + "cornerRadius": 16, + "layout": "vertical", + "gap": 12, + "padding": [ + 24, + 20 + ], + "children": [ + { + "type": "text", + "id": "CPDRU", + "name": "propTitle", + "fill": "#7D6B3D", + "content": "PROPOSED STATE", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "700", + "letterSpacing": 2 + }, + { + "type": "frame", + "id": "eAcKo", + "name": "propToolbar", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NGHcx", + "name": "propFilter", + "fill": "#8F8075", + "content": "全部 · 订阅 · API Key · 未启用", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "pZ6QI", + "name": "propAddBtn", + "fill": "#D49266", + "cornerRadius": 9999, + "padding": [ + 8, + 16 + ], + "children": [ + { + "type": "text", + "id": "fE9J4", + "name": "propAddLbl", + "fill": "#FFFFFF", + "content": "+ 添加成员", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "cKVVY", + "name": "coCard2", + "width": "fill_container", + "fill": "#FFF8F0", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#D4A76A" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "iDwum", + "name": "coRow2", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "2lG8O", + "name": "coLeft2", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "UfAzy", + "name": "coAvatar2", + "fill": "#D4A76A", + "width": 32, + "height": 32 + }, + { + "type": "text", + "id": "HmwEL", + "name": "coName2", + "fill": "#2D2118", + "content": "铲屎官", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "nEmUI", + "name": "ownerBadge2", + "fill": "#FFF3E0", + "cornerRadius": 9999, + "gap": 4, + "padding": [ + 4, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QRNlr", + "name": "ownerIcon2", + "width": 12, + "height": 12, + "iconFontName": "lock", + "iconFontFamily": "lucide", + "fill": "#E65100" + }, + { + "type": "text", + "id": "Ia13u", + "name": "ownerLbl2", + "fill": "#E65100", + "content": "Owner", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "IcO2D", + "name": "coMeta2", + "fill": "#8A776B", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "别名: 铲屎官 · 只能编辑,不能新增或删除", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "muXOJ", + "name": "coMention2", + "fill": "#D4A76A", + "content": "@co-creator @铲屎官", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "NXLoH", + "name": "opusCard2", + "width": "fill_container", + "fill": "#FFFDFC", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#D49266" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "U2N9w", + "name": "opusRow2", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "frame", + "id": "RJykU", + "name": "opusLeft2", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "gsO2h", + "name": "opusNameRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "KXiWk", + "name": "opusName2", + "fill": "#2D2118", + "content": "布偶猫 · 宪宪", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "VxU0Z", + "name": "defBadge", + "fill": "#FFF3E0", + "cornerRadius": 9999, + "gap": 4, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "LrJtG", + "name": "defIcon", + "width": 11, + "height": 11, + "iconFontName": "star", + "iconFontFamily": "lucide", + "fill": "#E65100" + }, + { + "type": "text", + "id": "x3HDg", + "name": "defLbl", + "fill": "#E65100", + "content": "默认回复", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "kRXgR", + "name": "opusStatus2", + "fill": "#E8F5E9", + "cornerRadius": 9999, + "padding": [ + 4, + 10 + ], + "children": [ + { + "type": "text", + "id": "4Pr5A", + "name": "opusStatusLbl2", + "fill": "#4CAF50", + "content": "已启用", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "JN0ER", + "name": "opusMeta2", + "fill": "#8A776B", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Anthropic · claude-opus-4-6 · 内置 OAuth 账号", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ZqThd", + "name": "opusMention2", + "fill": "#9D7BC7", + "content": "@opus @宪宪 @布偶猫", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "P0zmQ", + "name": "sonnetCard2", + "width": "fill_container", + "fill": "#FFFDFC", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1E7DF" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "eex4h", + "name": "sonnetRow2", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "aC0aL", + "name": "sonnetName2", + "fill": "#2D2118", + "content": "布偶猫 · 克拉", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "GkpNJ", + "name": "sonnetBadge2", + "fill": "#E8F5E9", + "cornerRadius": 9999, + "padding": [ + 4, + 10 + ], + "children": [ + { + "type": "text", + "id": "y1ebZ", + "name": "sonnetLbl2", + "fill": "#4CAF50", + "content": "已启用", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "Ud0fK", + "name": "sonnetMeta2", + "fill": "#8A776B", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Anthropic · claude-sonnet-4-6 · 内置 OAuth 账号", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "iAsZ3", + "name": "sonnetMention2", + "fill": "#9D7BC7", + "content": "@sonnet @克拉", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "o1DmH", + "name": "propHint", + "fill": "#B59A88", + "content": "点击任意卡片进入成员配置 →", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "2PYl7", + "x": 840, + "y": 0, + "name": "Edit Member — 默认回复 Section", + "width": 360, + "fill": "#F9F6F3", + "cornerRadius": 16, + "layout": "vertical", + "gap": 12, + "padding": [ + 24, + 20 + ], + "children": [ + { + "type": "text", + "id": "hekCy", + "name": "editTitle", + "fill": "#7D6B3D", + "content": "EDIT MEMBER(片段)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "700", + "letterSpacing": 2 + }, + { + "type": "frame", + "id": "Y1lIW", + "name": "secAlias", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1E7DF" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "text", + "id": "wfeqa", + "name": "secAliasTitle", + "fill": "#8A776B", + "content": "别名与 @ 路由", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "IdMY9", + "name": "secAliasContent", + "fill": "#9D7BC7", + "content": "@sonnet @克拉", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "bRDdF", + "name": "secAliasHint", + "fill": "#B59A88", + "content": "(已有 section,此处省略细节)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hHEfU", + "name": "secRouting", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#D49266" + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "jVKJA", + "name": "secRoutingTitle", + "fill": "#2D2118", + "content": "路由与默认", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "QhyqD", + "name": "toggleRow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "MmzuK", + "name": "toggleLblCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "vpV8W", + "name": "toggleLbl", + "fill": "#2D2118", + "content": "全局默认回复猫", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "c8WQj", + "name": "toggleHint", + "fill": "#8A776B", + "textGrowth": "fixed-width", + "width": 230, + "content": "仅影响新会话无历史时第一条消息,不影响已有 thread 的续接逻辑", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "fh33S", + "name": "toggleTrack", + "width": 44, + "height": 24, + "fill": "#E0E0E0", + "cornerRadius": 9999, + "padding": 2, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "c4NHr", + "name": "toggleKnob", + "fill": "#FFFFFF", + "width": 20, + "height": 20, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 2 + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "SIfpM", + "name": "infoBox", + "width": "fill_container", + "fill": "#FFF8F0", + "cornerRadius": 10, + "gap": 8, + "padding": [ + 10, + 12 + ], + "children": [ + { + "type": "icon_font", + "id": "3s03R", + "name": "infoIcon", + "width": 14, + "height": 14, + "iconFontName": "info", + "iconFontFamily": "lucide", + "fill": "#D49266" + }, + { + "type": "text", + "id": "BaWSk", + "name": "infoText", + "fill": "#8A776B", + "textGrowth": "fixed-width", + "width": 220, + "content": "当前默认: 布偶猫·宪宪(opus)。设为此成员后,宪宪的默认状态将自动取消。若此成员不可用,将退回 breed 默认。", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "AfLwa", + "name": "secStatus", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1E7DF" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "text", + "id": "PgOwO", + "name": "secStatusTitle", + "fill": "#8A776B", + "content": "启用状态", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "W22mt", + "name": "statusRow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iXRxp", + "name": "statusLbl", + "fill": "#2D2118", + "content": "已启用", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nCPjB", + "name": "statusTrack", + "width": 44, + "height": 24, + "fill": "#4CAF50", + "cornerRadius": 9999, + "padding": 2, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "IAkjV", + "name": "statusKnob", + "fill": "#FFFFFF", + "width": 20, + "height": 20, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 2 + } + } + ] + } + ] + }, + { + "type": "text", + "id": "Ouy82", + "name": "secStatusHint", + "fill": "#B59A88", + "content": "(已有 section,此处省略细节)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/features/F078-smart-routing-group-mentions.md b/docs/features/F078-smart-routing-group-mentions.md index 410689b5e..86d131a21 100644 --- a/docs/features/F078-smart-routing-group-mentions.md +++ b/docs/features/F078-smart-routing-group-mentions.md @@ -19,7 +19,7 @@ When users send messages without @mention, the system currently routes to ALL th Four routing improvements: -1. **Default to last replier** -- When no @mention is present and the thread has participants, route only to the most recent replier (not all participants). When `preferredCats` is set, last-replier is scoped to that set; if last replier is outside preferred, falls back to first preferred cat (#58). No participants and no preferredCats -> default to opus. +1. **Default to last replier** -- When no @mention is present and the thread has participants, route only to the most recent replier (not all participants). When `preferredCats` is set, last-replier is scoped to that set; if last replier is outside preferred, falls back to first preferred cat (#58). No participants and no preferredCats -> default to configured default responder (see #385, `getDefaultResponderCatId()`). 2. **@all / @全体** -- Route to all available cats. @@ -30,7 +30,7 @@ Four routing improvements: ## Acceptance Criteria - [x] AC-A1: Message without @mention routes to the cat that most recently replied in the thread -- [x] AC-A2: New thread without participants defaults to opus (unchanged) +- [x] AC-A2: New thread without participants defaults to configured default responder (#385 made this configurable; was opus) - [x] AC-A3: `@all` or `@全体` routes to all available cats - [x] AC-A4: `@全体Ragdoll` / `@all-ragdoll` routes to all ragdoll variants - [x] AC-A5: `@全体Maine Coon` / `@all-maine-coon` routes to all maine-coon variants @@ -43,7 +43,7 @@ Four routing improvements: ## Key Decisions - Group mentions are parsed BEFORE individual mentions (they are longer patterns) -- `@thread` requires ThreadStore access; if no participants, falls back to default cat (opus) +- `@thread` requires ThreadStore access; if no participants, falls back to configured default responder (#385) - Breed group patterns derived from `cat-config.json` breeds array (not hardcoded) - Token boundary matching prevents substring collisions (e.g. `@allison` ≠ `@all`) @@ -67,7 +67,7 @@ Four routing improvements: | # | Requirement | Source | AC | Status | |---|------------|--------|-----|--------| | R1 | Default to last replier when no @mention | Interview | AC-1 | done | -| R2 | New thread defaults to opus | Interview | AC-2 | done | +| R2 | New thread defaults to configured default responder (#385) | Interview | AC-2 | done | | R3 | @all broadcasts to all cats | Interview | AC-3 | done | | R4 | Per-breed group mentions | Interview | AC-4,5,6 | done | | R5 | @thread mentions all participants | Interview | AC-7 | done | diff --git a/packages/api/src/config/ConfigRegistry.ts b/packages/api/src/config/ConfigRegistry.ts index 2e089c671..e24a99c26 100644 --- a/packages/api/src/config/ConfigRegistry.ts +++ b/packages/api/src/config/ConfigRegistry.ts @@ -10,7 +10,7 @@ import { CAT_CONFIGS, catRegistry } from '@cat-cafe/shared'; import { DEFAULT_CLI_TIMEOUT_MS, readCliTimeoutMsFromEnv } from '../utils/cli-timeout.js'; import { configStore } from './ConfigStore.js'; import { getAllCatBudgets } from './cat-budgets.js'; -import { getCoCreatorConfig } from './cat-config-loader.js'; +import { getCoCreatorConfig, getDefaultResponderCatId } from './cat-config-loader.js'; import { getCatModel } from './cat-models.js'; import { getCodexApprovalPolicy, getCodexSandboxMode } from './codex-cli.js'; import type { CodexAuthMode, ConfigSnapshot } from './config-snapshot.js'; @@ -133,6 +133,7 @@ export function collectConfigSnapshot(): ConfigSnapshot { doneTimeoutMs: 5 * 60 * 1000, heartbeatIntervalMs: 30_000, }, + defaultResponderCatId: getDefaultResponderCatId(), deliberate: { status: 'types_only' }, codexExecution: { model: codexExecutionModel, diff --git a/packages/api/src/config/cat-config-loader.ts b/packages/api/src/config/cat-config-loader.ts index 000e96513..fb846fbdb 100644 --- a/packages/api/src/config/cat-config-loader.ts +++ b/packages/api/src/config/cat-config-loader.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url'; import type { CatBreed, CatCafeConfig, + CatCafeConfigV2, CatConfig, CatFeatures, CatId, @@ -208,6 +209,8 @@ const catCafeConfigSchemaV2 = z coCreator: coCreatorConfigSchema.optional(), /** @deprecated Accepted for backward compat; migrated to coCreator at parse time. */ owner: coCreatorConfigSchema.optional(), + /** #385: Configurable default responder for new threads. */ + defaultResponderCatId: z.string().min(1).optional(), }) .transform((data) => { // Migrate legacy "owner" key → "coCreator" (coCreator takes precedence) @@ -665,6 +668,24 @@ export function hasRuntimeDefaultCatOverride(): boolean { return _runtimeDefaultCatId !== null; } +/** + * Get the configured default responder for new threads (#385). + * + * Used when: new thread, no history, no @mention, no preferredCats. + * Fallback chain: config.defaultResponderCatId → getDefaultCatId() → 'opus'. + * + * Does NOT change reviewer-matcher or error-broadcast fallback semantics. + */ +export function getDefaultResponderCatId(): CatId { + const config = getCachedConfig(); + if (config?.version === 2) { + const configured = (config as CatCafeConfigV2).defaultResponderCatId; + const roster = getRoster(config); + if (configured && configured in roster && isCatAvailable(configured, config)) return createCatId(configured); + } + return getDefaultCatId(); +} + // ── Variant CLI effort accessor ────────────────────────────────────── /** catId → variant index (lazy, rebuilt on config change) */ diff --git a/packages/api/src/config/config-snapshot.ts b/packages/api/src/config/config-snapshot.ts index 7803e3dd0..9547a7b52 100644 --- a/packages/api/src/config/config-snapshot.ts +++ b/packages/api/src/config/config-snapshot.ts @@ -86,6 +86,8 @@ export interface ConfigSnapshot { embedMode: string; abstractiveEnabled: boolean; }; + /** #385: Configured default responder for new threads (resolved catId) */ + defaultResponderCatId: string; /** UI display preferences (bubble expand/collapse defaults) */ ui: { bubbleDefaults: { diff --git a/packages/api/src/config/runtime-cat-catalog.ts b/packages/api/src/config/runtime-cat-catalog.ts index 639fcf5dd..c3689049a 100644 --- a/packages/api/src/config/runtime-cat-catalog.ts +++ b/packages/api/src/config/runtime-cat-catalog.ts @@ -483,6 +483,23 @@ export function updateRuntimeCoCreator(projectRoot: string, patch: RuntimeCoCrea return writeAndValidateCatalog(projectRoot, catalog); } +/** + * #385: Update (or clear) the default responder cat for new threads. + * Writes to cat-catalog.json `defaultResponderCatId` field. + */ +export function updateDefaultResponderCatId(projectRoot: string, catId: string | null): CatCafeConfig { + const catalog = cloneCatalog(readOrBootstrapCatalog(projectRoot)); + if (catalog.version !== 2) { + throw new Error('defaultResponderCatId requires a version 2 runtime catalog'); + } + if (catId) { + catalog.defaultResponderCatId = catId; + } else { + delete catalog.defaultResponderCatId; + } + return writeAndValidateCatalog(projectRoot, catalog); +} + export function deleteRuntimeCat(projectRoot: string, catId: string): CatCafeConfig { const catalog = cloneCatalog(readOrBootstrapCatalog(projectRoot)); const located = findBreedVariant(catalog as unknown as CatCafeConfig, catId); diff --git a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts index f41b90f1d..af7da487f 100644 --- a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts +++ b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts @@ -21,7 +21,7 @@ import type { CatId, MessageContent } from '@cat-cafe/shared'; import { catRegistry, escapeRegExp } from '@cat-cafe/shared'; import type { SessionStore } from '@cat-cafe/shared/utils'; -import { getDefaultCatId, isCatAvailable } from '../../../../../config/cat-config-loader.js'; +import { getDefaultCatId, getDefaultResponderCatId, isCatAvailable } from '../../../../../config/cat-config-loader.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import type { IntentResult } from '../../context/IntentParser.js'; import { parseIntent, stripIntentTags } from '../../context/IntentParser.js'; @@ -584,10 +584,10 @@ export class AgentRouter { return this.applyThreadRoutingPolicy(thread, message, [validPreferred[0]]); } - return this.applyThreadRoutingPolicy(thread, message, [getDefaultCatId()]); + return this.applyThreadRoutingPolicy(thread, message, [getDefaultResponderCatId()]); } - return [getDefaultCatId()]; + return [getDefaultResponderCatId()]; } /** Resolve target cats and persist new mentions as thread participants */ @@ -639,10 +639,10 @@ export class AgentRouter { return this.applyThreadRoutingPolicy(thread, message, [validPreferred[0]]); } - return this.applyThreadRoutingPolicy(thread, message, [getDefaultCatId()]); + return this.applyThreadRoutingPolicy(thread, message, [getDefaultResponderCatId()]); } - return [getDefaultCatId()]; + return [getDefaultResponderCatId()]; } /** Build shared strategy dependencies (public for ModeOrchestrator) */ diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 63bff46c3..63f41628d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -591,7 +591,7 @@ async function main(): Promise { const { TaskRunnerV2 } = await import('./infrastructure/scheduler/TaskRunnerV2.js'); const { RunLedger } = await import('./infrastructure/scheduler/RunLedger.js'); const { createActorResolver } = await import('./infrastructure/scheduler/ActorResolver.js'); - const { getRoster } = await import('./config/cat-config-loader.js'); + const { getRoster, getDefaultResponderCatId } = await import('./config/cat-config-loader.js'); const schedulerDb = memoryServices.store.getDb(); const runLedger = new RunLedger(schedulerDb); const actorResolver = createActorResolver(getRoster); @@ -2084,7 +2084,7 @@ async function main(): Promise { invokeTrigger, socketManager, defaultUserId: 'default-user' as const, - defaultCatId: 'opus' as CatId, + defaultCatId: getDefaultResponderCatId(), redis: redisClient ?? undefined, log: app.log, agentRegistry, diff --git a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts index f2049449e..098608dd5 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts @@ -17,6 +17,7 @@ import type { CatId, ConnectorSource, MessageContent } from '@cat-cafe/shared'; import { catRegistry, getConnectorDefinition } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; +import { getDefaultResponderCatId } from '../../config/cat-config-loader.js'; import { findMonorepoRoot } from '../../utils/monorepo-root.js'; import type { ConnectorCommandLayer } from './ConnectorCommandLayer.js'; import { ConnectorMessageFormatter } from './ConnectorMessageFormatter.js'; @@ -282,7 +283,7 @@ export class ConnectorRouter { icon: def2?.icon ?? 'message', }; const mentionPatterns = this.getMentionPatterns(); - const { targetCatId } = parseMentions(fwdText, mentionPatterns, this.opts.defaultCatId); + const { targetCatId } = parseMentions(fwdText, mentionPatterns, getDefaultResponderCatId()); const fwdTimestamp = Date.now(); const fwdStored = await messageStore.append({ threadId: fwdThreadId, @@ -427,7 +428,7 @@ export class ConnectorRouter { // Parse @-mentions to determine target cat const mentionPatterns = this.getMentionPatterns(); - const mentionResult = parseMentions(resolvedText, mentionPatterns, this.opts.defaultCatId); + const mentionResult = parseMentions(resolvedText, mentionPatterns, getDefaultResponderCatId()); let targetCatId = mentionResult.targetCatId; if (!mentionResult.matched && this.opts.threadStore.getParticipantsWithActivity) { const participants = await this.opts.threadStore.getParticipantsWithActivity(binding.threadId); diff --git a/packages/api/src/routes/config.ts b/packages/api/src/routes/config.ts index ba9613933..b66eea2fe 100644 --- a/packages/api/src/routes/config.ts +++ b/packages/api/src/routes/config.ts @@ -28,7 +28,7 @@ import { hasSensitiveEditableVars, isEditableEnvVarName, } from '../config/env-registry.js'; -import { updateRuntimeCoCreator } from '../config/runtime-cat-catalog.js'; +import { updateDefaultResponderCatId, updateRuntimeCoCreator } from '../config/runtime-cat-catalog.js'; import { AuditEventTypes, getEventAuditLog } from '../domains/cats/services/orchestration/EventAuditLog.js'; import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; @@ -243,6 +243,58 @@ export async function configRoutes(app: FastifyInstance, opts: ConfigRoutesOptio return handleCoCreatorPatch(request, reply); }); + // #385: Set/clear default responder cat for new threads + const defaultResponderSchema = z.object({ + catId: z.string().min(1).nullable(), + }); + + app.patch('/api/config/default-responder', async (request, reply) => { + const parsed = defaultResponderSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Invalid request', details: parsed.error.issues }; + } + const operator = resolveHeaderUserId(request); + if (!operator) { + reply.status(400); + return { error: 'Identity required' }; + } + + // Validate that catId refers to a known, registered cat + if (parsed.data.catId) { + const { catRegistry } = await import('@cat-cafe/shared'); + if (!catRegistry.has(parsed.data.catId)) { + reply.status(400); + return { + error: `Unknown cat '${parsed.data.catId}' — cat must be registered before setting as default responder`, + }; + } + } + + try { + updateDefaultResponderCatId(projectRoot, parsed.data.catId); + } catch (err) { + reply.status(400); + return { error: err instanceof Error ? err.message : String(err) }; + } + + const next = collectConfigSnapshot(); + try { + await auditLog.append({ + type: AuditEventTypes.CONFIG_UPDATED, + data: { + target: 'defaultResponderCatId', + operator, + value: parsed.data.catId, + }, + }); + } catch (err) { + request.log.warn({ err }, 'defaultResponderCatId audit append failed'); + } + + return { config: next }; + }); + app.get('/api/config/env-summary', async () => { const apiCwd = process.cwd(); const home = os.homedir(); diff --git a/packages/api/test/cat-config-loader.test.js b/packages/api/test/cat-config-loader.test.js index c59075c5b..f4b965374 100644 --- a/packages/api/test/cat-config-loader.test.js +++ b/packages/api/test/cat-config-loader.test.js @@ -14,6 +14,7 @@ const { isSessionChainEnabled, getMissionHubSelfClaimScope, getDefaultCatId, + getDefaultResponderCatId, buildCatIdToBreedIndex, getCatEffort, _resetCachedConfig, @@ -1013,4 +1014,152 @@ describe('GPT-5.2 variant mention aliases in project config', () => { assert.ok(gpt52, 'gpt52 cat config exists'); assert.ok(gpt52.mentionPatterns.includes('@gpt')); }); + + // ── #385: getDefaultResponderCatId ───────────────────────────────── + + describe('getDefaultResponderCatId (#385)', () => { + /** Minimal v2 config with roster */ + function validV2Config(extra = {}) { + return { + version: 2, + breeds: [ + { + id: 'ragdoll', + catId: 'opus', + name: '布偶猫', + displayName: '布偶猫', + avatar: '/avatars/opus.png', + color: { primary: '#9B7EBD', secondary: '#E8DFF5' }, + mentionPatterns: ['@opus'], + roleDescription: '主架构师', + defaultVariantId: 'opus-default', + variants: [ + { + id: 'opus-default', + clientId: 'anthropic', + defaultModel: 'claude-opus-4-6', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + personality: '温柔', + }, + { + id: 'sonnet-variant', + catId: 'sonnet', + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-6', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + personality: '灵活', + }, + ], + }, + ], + roster: { + opus: { family: 'ragdoll', roles: ['architect'], lead: true, available: true, evaluation: 'lead' }, + sonnet: { family: 'ragdoll', roles: ['general'], lead: false, available: true, evaluation: 'flex' }, + }, + reviewPolicy: { + requireDifferentFamily: false, + preferActiveInThread: true, + preferLead: false, + excludeUnavailable: true, + }, + ...extra, + }; + } + + it('falls back to getDefaultCatId when no defaultResponderCatId configured', () => { + const saved = process.env.CAT_TEMPLATE_PATH; + const path = writeTempConfig(validV2Config()); + process.env.CAT_TEMPLATE_PATH = path; + _resetCachedConfig(); + try { + assert.equal(getDefaultResponderCatId(), getDefaultCatId()); + } finally { + if (saved === undefined) delete process.env.CAT_TEMPLATE_PATH; + else process.env.CAT_TEMPLATE_PATH = saved; + _resetCachedConfig(); + } + }); + + it('returns configured defaultResponderCatId when set', () => { + const saved = process.env.CAT_TEMPLATE_PATH; + const path = writeTempConfig(validV2Config({ defaultResponderCatId: 'sonnet' })); + process.env.CAT_TEMPLATE_PATH = path; + _resetCachedConfig(); + try { + assert.equal(getDefaultResponderCatId(), 'sonnet'); + } finally { + if (saved === undefined) delete process.env.CAT_TEMPLATE_PATH; + else process.env.CAT_TEMPLATE_PATH = saved; + _resetCachedConfig(); + } + }); + + it('does not affect getDefaultCatId when defaultResponderCatId is set', () => { + const saved = process.env.CAT_TEMPLATE_PATH; + const path = writeTempConfig(validV2Config({ defaultResponderCatId: 'sonnet' })); + process.env.CAT_TEMPLATE_PATH = path; + _resetCachedConfig(); + try { + // getDefaultCatId should still return breed-based default (opus) + assert.equal(getDefaultCatId(), 'opus'); + // getDefaultResponderCatId should return the configured one + assert.equal(getDefaultResponderCatId(), 'sonnet'); + } finally { + if (saved === undefined) delete process.env.CAT_TEMPLATE_PATH; + else process.env.CAT_TEMPLATE_PATH = saved; + _resetCachedConfig(); + } + }); + + it('falls back through chain when v1 config (no defaultResponderCatId field)', () => { + const saved = process.env.CAT_TEMPLATE_PATH; + const path = writeTempConfig(validConfig()); + process.env.CAT_TEMPLATE_PATH = path; + _resetCachedConfig(); + try { + // v1 config → no defaultResponderCatId → falls back to getDefaultCatId + assert.equal(getDefaultResponderCatId(), getDefaultCatId()); + } finally { + if (saved === undefined) delete process.env.CAT_TEMPLATE_PATH; + else process.env.CAT_TEMPLATE_PATH = saved; + _resetCachedConfig(); + } + }); + + it('falls back when configured cat is deleted from roster (stale ID)', () => { + const saved = process.env.CAT_TEMPLATE_PATH; + const cfg = validV2Config({ defaultResponderCatId: 'ghost' }); + // 'ghost' is not in roster at all — simulates a deleted cat + const path = writeTempConfig(cfg); + process.env.CAT_TEMPLATE_PATH = path; + _resetCachedConfig(); + try { + assert.equal(getDefaultResponderCatId(), 'opus'); + } finally { + if (saved === undefined) delete process.env.CAT_TEMPLATE_PATH; + else process.env.CAT_TEMPLATE_PATH = saved; + _resetCachedConfig(); + } + }); + + it('falls back to getDefaultCatId when configured cat is unavailable', () => { + const saved = process.env.CAT_TEMPLATE_PATH; + const cfg = validV2Config({ defaultResponderCatId: 'sonnet' }); + // Mark sonnet as unavailable + cfg.roster.sonnet.available = false; + const path = writeTempConfig(cfg); + process.env.CAT_TEMPLATE_PATH = path; + _resetCachedConfig(); + try { + // sonnet is available=false → should fall back to breed default (opus) + assert.equal(getDefaultResponderCatId(), 'opus'); + } finally { + if (saved === undefined) delete process.env.CAT_TEMPLATE_PATH; + else process.env.CAT_TEMPLATE_PATH = saved; + _resetCachedConfig(); + } + }); + }); }); diff --git a/packages/shared/src/types/cat-breed.ts b/packages/shared/src/types/cat-breed.ts index 2e6823509..1093ff064 100644 --- a/packages/shared/src/types/cat-breed.ts +++ b/packages/shared/src/types/cat-breed.ts @@ -262,6 +262,8 @@ export interface CatCafeConfigV2 { * New code must use catalog-accounts.ts which reads the global file. */ readonly accounts?: Readonly>; + /** #385: Configurable default responder for new threads (no history, no @mention). */ + readonly defaultResponderCatId?: string; } /** diff --git a/packages/web/src/components/ChatContainer.tsx b/packages/web/src/components/ChatContainer.tsx index b64835b6e..0ee11589a 100644 --- a/packages/web/src/components/ChatContainer.tsx +++ b/packages/web/src/components/ChatContainer.tsx @@ -78,6 +78,7 @@ export function ChatContainer({ threadId }: ChatContainerProps) { rightPanelMode, } = useChatStore(); const uiThinkingExpandedByDefault = useChatStore((s) => s.uiThinkingExpandedByDefault); + const defaultResponderCatId = useChatStore((s) => s.defaultResponderCatId); // F101: Game state from Zustand store const gameView = useGameStore((s) => s.gameView); @@ -137,6 +138,10 @@ export function ChatContainer({ threadId }: ChatContainerProps) { cancelled = true; }; }, []); + // #385: hydrate defaultResponderCatId independently of sidebar lifecycle + useEffect(() => { + void useChatStore.getState().fetchGlobalBubbleDefaults(); + }, []); // F063: resizable split pane — chatBasis as percentage (20-80), persisted const [chatBasis, setChatBasis, resetChatBasis] = usePersistedState('cat-cafe:chatBasis', 50); // clowder-ai#28: right status panel width in px, persisted @@ -552,7 +557,7 @@ export function ChatContainer({ threadId }: ChatContainerProps) { onOpenMobileStatus={() => setMobileStatusOpen(true)} statusPanelOpen={statusPanelOpen} onToggleStatusPanel={() => setStatusPanelOpen((v) => !v)} - defaultCatId={targetCats[0] || 'opus'} + defaultCatId={targetCats[0] || defaultResponderCatId} /> {intentMode === 'ideate' && } diff --git a/packages/web/src/components/HubCatEditor.tsx b/packages/web/src/components/HubCatEditor.tsx index a6b1e4022..3a0d4b863 100644 --- a/packages/web/src/components/HubCatEditor.tsx +++ b/packages/web/src/components/HubCatEditor.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { CatData } from '@/hooks/useCatData'; +import { useChatStore } from '@/stores/chatStore'; import { apiFetch } from '@/utils/api-client'; import type { ConfigData } from './config-viewer-types'; import type { AccountsResponse, ProfileItem } from './hub-accounts.types'; @@ -55,6 +56,8 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito const [strategyBaselineHasOverride, setStrategyBaselineHasOverride] = useState(false); const [codexSettings, setCodexSettings] = useState(null); const [codexSettingsBaseline, setCodexSettingsBaseline] = useState(null); + const [defaultResponderCatId, setDefaultResponderCatId] = useState(null); + const [defaultResponderName, setDefaultResponderName] = useState(null); const availableProfiles = useMemo(() => filterAccounts(form.clientId, profiles), [form.clientId, profiles]); const selectedProfile = useMemo( @@ -111,6 +114,27 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito }; }, [open, profilesVersion]); + // #385: Fetch current default responder + useEffect(() => { + if (!open) return; + let cancelled = false; + apiFetch('/api/config') + .then(async (res) => { + if (!res.ok) return; + const body = (await res.json()) as { config: ConfigData }; + if (cancelled) return; + const id = body.config.defaultResponderCatId ?? null; + setDefaultResponderCatId(id); + if (id && body.config.cats[id]) { + setDefaultResponderName(body.config.cats[id].displayName); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [open]); + useEffect(() => { if (!open || !cat) { setStrategyForm(null); @@ -526,7 +550,31 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito loadingProfiles={loadingProfiles} onChange={patchForm} /> - + { + const res = await apiFetch('/api/config/default-responder', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ catId: value ? cat.id : null }), + }); + if (res.ok) { + const body = (await res.json()) as { config: ConfigData }; + const confirmed = body.config?.defaultResponderCatId ?? null; + setDefaultResponderCatId(confirmed); + setDefaultResponderName(value ? form.displayName || form.name : null); + useChatStore.setState({ defaultResponderCatId: confirmed ?? 'opus' }); + } + } + : undefined + } + /> void; onToggleAvailability?: (cat: CatData) => void; togglingAvailability?: boolean; + isDefaultResponder?: boolean; }) { const status = getStatusBadge(cat); const title = [cat.breedDisplayName ?? cat.displayName, cat.nickname].filter(Boolean).join(' · '); @@ -179,12 +181,25 @@ export function HubMemberOverviewCard({ } }} className="rounded-[20px] px-[18px] py-[18px] shadow-sm transition hover:shadow-md" - style={{ backgroundColor: '#FFFDFC', border: `1px solid ${cat.source === 'runtime' ? '#D9C7EA' : '#F1E7DF'}` }} + style={{ + backgroundColor: '#FFFDFC', + border: isDefaultResponder + ? '2px solid #D49266' + : `1px solid ${cat.source === 'runtime' ? '#D9C7EA' : '#F1E7DF'}`, + }} >

{title}

+ {isDefaultResponder ? ( + + + + + 默认回复 + + ) : null} {cat.source === 'runtime' ? ( 动态创建 diff --git a/packages/web/src/components/__tests__/hub-add-member-wizard.test.tsx b/packages/web/src/components/__tests__/hub-add-member-wizard.test.tsx index 9f87585c6..6d2feb263 100644 --- a/packages/web/src/components/__tests__/hub-add-member-wizard.test.tsx +++ b/packages/web/src/components/__tests__/hub-add-member-wizard.test.tsx @@ -215,6 +215,9 @@ describe('HubAddMemberWizard', () => { }), ); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); }); diff --git a/packages/web/src/components/__tests__/hub-cat-editor.test.tsx b/packages/web/src/components/__tests__/hub-cat-editor.test.tsx index 943486abc..454ef7464 100644 --- a/packages/web/src/components/__tests__/hub-cat-editor.test.tsx +++ b/packages/web/src/components/__tests__/hub-cat-editor.test.tsx @@ -309,6 +309,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-spark' } }, 201)); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -518,6 +521,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats' && init?.method === 'POST') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-opencode' } }, 201)); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -767,6 +773,9 @@ describe('HubCatEditor', () => { }), ); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -909,6 +918,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats/runtime-codex' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-codex' } })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -997,6 +1009,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats/runtime-codex' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-codex' } })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1084,6 +1099,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats/runtime-opencode' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-opencode' } })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1141,6 +1159,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats/runtime-opencode' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-opencode' } })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1257,6 +1278,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats/runtime-codex' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-codex' } })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1334,6 +1358,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats/runtime-codex' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-codex' } })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1421,6 +1448,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats/runtime-codex' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-codex' } })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1481,6 +1511,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats') { return Promise.resolve(jsonResponse({ cat: { id: 'runtime-spark' } }, 201)); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1537,6 +1570,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats/runtime-antigravity') { return Promise.resolve(jsonResponse({ deleted: true })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1637,6 +1673,9 @@ describe('HubCatEditor', () => { if (path === '/api/config' && !init?.method) { return Promise.resolve(jsonResponse({ config: { cli: {}, codexExecution: {} } })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1784,6 +1823,9 @@ describe('HubCatEditor', () => { }), ); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -1952,6 +1994,9 @@ describe('HubCatEditor', () => { if (path === '/api/config/session-strategy/codex' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ ok: true })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -2030,6 +2075,9 @@ describe('HubCatEditor', () => { if (path === '/api/config' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ config: {} })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -2149,6 +2197,9 @@ describe('HubCatEditor', () => { if (path === '/api/config' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ error: 'Codex PATCH failed' }, 500)); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -2225,6 +2276,9 @@ describe('HubCatEditor', () => { if (path === '/api/config' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ config: {} })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -2327,6 +2381,9 @@ describe('HubCatEditor', () => { if (path === '/api/config' && init?.method === 'PATCH') { return Promise.resolve(jsonResponse({ error: 'Codex PATCH failed' }, 500)); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -2458,6 +2515,9 @@ describe('HubCatEditor', () => { } return Promise.resolve(jsonResponse({ config: {} })); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -2598,6 +2658,9 @@ describe('HubCatEditor', () => { if (path === '/api/cats/codex' && init?.method === 'PATCH') { return Promise.reject(new Error('network dropped during cat save')); } + if (path === '/api/config') { + return Promise.resolve(jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {} } })); + } throw new Error(`Unexpected apiFetch path: ${path}`); }); @@ -2626,4 +2689,103 @@ describe('HubCatEditor', () => { expect(container.textContent).toContain('network dropped during cat save'); expect(onSaved).not.toHaveBeenCalled(); }); + + it('#385: shows default responder toggle in editor and sends PATCH on click', async () => { + mockApiFetch.mockImplementation((path: string, init?: RequestInit) => { + if (path === '/api/accounts') { + return Promise.resolve( + jsonResponse({ + projectPath: '/tmp/project', + activeProfileId: null, + providers: [ + { + id: 'anthropic', + provider: 'anthropic', + displayName: 'Anthropic', + name: 'Anthropic', + authType: 'oauth', + protocol: 'anthropic', + builtin: true, + mode: 'subscription', + models: ['claude-opus-4-6'], + hasApiKey: false, + createdAt: '', + updatedAt: '', + }, + ], + }), + ); + } + if (path === '/api/config') { + return Promise.resolve( + jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {}, defaultResponderCatId: 'opus' } }), + ); + } + if (path === '/api/config/default-responder' && init?.method === 'PATCH') { + // Server returns effective config — when clearing, breed default kicks in (e.g. 'spark') + return Promise.resolve( + jsonResponse({ config: { cats: {}, cli: {}, codexExecution: {}, defaultResponderCatId: 'spark' } }), + ); + } + if (path.startsWith('/api/config/session-strategy')) { + return Promise.resolve(jsonResponse({ strategy: 'compress' })); + } + if (path.startsWith('/api/config/codex-settings')) { + return Promise.resolve(jsonResponse({})); + } + throw new Error(`Unexpected apiFetch path: ${path}`); + }); + + await act(async () => { + root.render( + React.createElement(HubCatEditor, { + open: true, + cat: { + id: 'opus', + displayName: 'Opus', + breedDisplayName: 'Ragdoll', + nickname: '', + clientId: 'anthropic', + defaultModel: 'claude-opus-4-6', + color: { primary: '#9B7EBD', secondary: '#E8DFF5' }, + mentionPatterns: ['@opus'], + avatar: '/avatars/opus.png', + roleDescription: '主架构师', + source: 'seed', + }, + onClose: vi.fn(), + onSaved: vi.fn(), + }), + ); + }); + await flushEffects(); + + // Toggle should be visible with label text + expect(container.textContent).toContain('全局默认回复猫'); + + // Find the toggle button (within the default responder section) + const toggleSection = Array.from(container.querySelectorAll('p')) + .find((p) => p.textContent === '全局默认回复猫') + ?.closest('div.mt-3'); + const toggleButton = toggleSection?.querySelector('button'); + expect(toggleButton).toBeTruthy(); + + // Click toggle to disable (opus is currently default) + await act(async () => { + toggleButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushEffects(); + + // Verify PATCH was sent to clear default responder + const patchCall = mockApiFetch.mock.calls.find( + ([p, i]) => p === '/api/config/default-responder' && i?.method === 'PATCH', + ); + expect(patchCall).toBeTruthy(); + const patchBody = JSON.parse(String(patchCall?.[1]?.body)); + expect(patchBody.catId).toBeNull(); + + // Verify chatStore was synced from server response (not local derivation) + const { useChatStore } = await import('@/stores/chatStore'); + expect(useChatStore.getState().defaultResponderCatId).toBe('spark'); + }); }); diff --git a/packages/web/src/components/config-viewer-tabs.tsx b/packages/web/src/components/config-viewer-tabs.tsx index 34d75e2bd..0970e8a9b 100644 --- a/packages/web/src/components/config-viewer-tabs.tsx +++ b/packages/web/src/components/config-viewer-tabs.tsx @@ -57,6 +57,7 @@ export function CatOverviewTab({ onEdit={onEditMember} onToggleAvailability={onToggleAvailability} togglingAvailability={togglingCatId === catData.id} + isDefaultResponder={config.defaultResponderCatId === catData.id} /> ))}
diff --git a/packages/web/src/components/config-viewer-types.ts b/packages/web/src/components/config-viewer-types.ts index d77bc2f0a..25e565976 100644 --- a/packages/web/src/components/config-viewer-types.ts +++ b/packages/web/src/components/config-viewer-types.ts @@ -50,4 +50,6 @@ export interface ConfigData { cliOutput: 'expanded' | 'collapsed'; }; }; + /** #385: Configured default responder cat for new threads */ + defaultResponderCatId?: string; } diff --git a/packages/web/src/components/hub-cat-editor.sections.tsx b/packages/web/src/components/hub-cat-editor.sections.tsx index ff84bbae5..6170a8209 100644 --- a/packages/web/src/components/hub-cat-editor.sections.tsx +++ b/packages/web/src/components/hub-cat-editor.sections.tsx @@ -473,11 +473,17 @@ export function RoutingSection({ form, hasError, onChange, + isDefaultResponder, + onSetDefaultResponder, + currentDefaultName, }: { cat?: CatData | null; form: HubCatEditorFormState; hasError?: boolean; onChange: (patch: FormPatch) => void; + isDefaultResponder?: boolean; + onSetDefaultResponder?: (value: boolean) => void; + currentDefaultName?: string; }) { const aliases = currentAliasTags(form); return ( @@ -497,6 +503,33 @@ export function RoutingSection({ placeholder="@codex, @缅因猫" className="sr-only" /> + + {onSetDefaultResponder ? ( +
+
+
+

全局默认回复猫

+

+ 仅影响新会话无历史时第一条消息,不影响已有 thread 的续接逻辑 +

+
+ +
+ {!isDefaultResponder && currentDefaultName ? ( +

+ 当前默认: {currentDefaultName}。设为此成员后,原默认将自动取消。 +

+ ) : null} +
+ ) : null} ); } diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 72a1b7ae8..62457bb24 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -465,6 +465,8 @@ interface ChatState { uiThinkingExpandedByDefault: boolean; /** Global bubble display defaults from Config Hub (server-side). */ globalBubbleDefaults: GlobalBubbleDefaults; + /** #385: Configured default responder cat for new threads. */ + defaultResponderCatId: string; // ── Active-thread actions (operate on flat state) ── addMessage: (msg: ChatMessage) => void; @@ -691,6 +693,7 @@ export const useChatStore = create((set, get) => ({ thinking: loadUiThinkingExpandedByDefault() ? 'expanded' : 'collapsed', cliOutput: 'collapsed', }, + defaultResponderCatId: 'opus', setGlobalBubbleDefaults: (defaults) => set({ globalBubbleDefaults: defaults }), @@ -700,15 +703,19 @@ export const useChatStore = create((set, get) => ({ const res = await apiFetch('/api/config'); if (!res.ok) return; const data = await res.json(); - const ui = data.config?.ui; + const cfg = data.config; + const ui = cfg?.ui; + const patch: Record = {}; if (ui?.bubbleDefaults) { - set({ - globalBubbleDefaults: { - thinking: ui.bubbleDefaults.thinking ?? 'collapsed', - cliOutput: ui.bubbleDefaults.cliOutput ?? 'collapsed', - }, - }); + patch.globalBubbleDefaults = { + thinking: ui.bubbleDefaults.thinking ?? 'collapsed', + cliOutput: ui.bubbleDefaults.cliOutput ?? 'collapsed', + }; + } + if (cfg?.defaultResponderCatId) { + patch.defaultResponderCatId = cfg.defaultResponderCatId; } + if (Object.keys(patch).length > 0) set(patch); } catch { // Fallback to existing defaults on network error }