diff --git a/apps/cube/frontend/src/apps.json b/apps/cube/frontend/src/apps.json new file mode 100644 index 0000000..c43eea4 --- /dev/null +++ b/apps/cube/frontend/src/apps.json @@ -0,0 +1,72 @@ +[ + { + "slug": "cube", + "name": "cube", + "description": "你正在看的这个门户。cube 平台本身的入口。", + "url": "https://cube.famzheng.me", + "status": "live" + }, + { + "slug": "portfolio", + "name": "portfolio", + "description": "投资组合追踪。从 oci 迁移中(原 portfolio.oci.euphon.net)。", + "url": "https://portfolio.famzheng.me", + "status": "pending" + }, + { + "slug": "repo-vis", + "name": "repo-vis", + "description": "git 仓库可视化。从 oci 迁移中。", + "url": "https://repo-vis.famzheng.me", + "status": "pending" + }, + { + "slug": "simpleasm", + "name": "simpleasm", + "description": "汇编教学小游戏。", + "url": "https://asm.famzheng.me", + "status": "live" + }, + { + "slug": "music", + "name": "music", + "description": "听歌 + 练琴 曲目管理。243 首曲库(从 oci 旧 guitar 迁过来)+ 自动抓 yopu 吉他/功能谱 + LLM 灵感推荐。", + "url": "https://music.famzheng.me", + "status": "live" + }, + { + "slug": "pyroblem", + "name": "pyroblem", + "description": "详情待补。", + "url": "https://pyroblem.famzheng.me", + "status": "tbd" + }, + { + "slug": "werewolf", + "name": "werewolf", + "description": "狼人杀单机发牌器。一台手机轮流传,30 个角色、4x 偏好加权、配置历史本地记忆。从 partiverse 移植。", + "url": "https://werewolf.famzheng.me", + "status": "live" + }, + { + "slug": "articulate", + "name": "articulate", + "description": "中英猜词派对游戏(Articulate)。15 个主题词库 + 3 档难度 + 已看词跨场记忆。从 partiverse 移植。", + "url": "https://articulate.famzheng.me", + "status": "live" + }, + { + "slug": "karaoke", + "name": "karaoke", + "description": "卡拉OK 点歌单本地管理。增删改排 + YouTube 一键搜,10 秒撤销。从 partiverse 移植。", + "url": "https://karaoke.famzheng.me", + "status": "live" + }, + { + "slug": "notes", + "name": "notes", + "description": "录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + content;passphrase 鉴权。", + "url": "https://notes.famzheng.me", + "status": "live" + } +] diff --git a/apps/cube/frontend/src/apps.ts b/apps/cube/frontend/src/apps.ts index 7e5e5b6..d78c2ae 100644 --- a/apps/cube/frontend/src/apps.ts +++ b/apps/cube/frontend/src/apps.ts @@ -1,3 +1,8 @@ +// apps 列表的 SSOT 是 apps.json — 后端 chat.rs 也 include_str! 同一份,注入到 +// chatbot 的 system prompt 里。改 apps 只改 apps.json。 + +import data from './apps.json' + export type AppStatus = 'live' | 'pending' | 'tbd' export interface App { @@ -8,75 +13,4 @@ export interface App { status: AppStatus } -export const apps: App[] = [ - { - slug: 'cube', - name: 'cube', - description: '你正在看的这个门户。cube 平台本身的入口。', - url: 'https://cube.famzheng.me', - status: 'live', - }, - { - slug: 'portfolio', - name: 'portfolio', - description: '投资组合追踪。从 oci 迁移中(原 portfolio.oci.euphon.net)。', - url: 'https://portfolio.famzheng.me', - status: 'pending', - }, - { - slug: 'repo-vis', - name: 'repo-vis', - description: 'git 仓库可视化。从 oci 迁移中。', - url: 'https://repo-vis.famzheng.me', - status: 'pending', - }, - { - slug: 'simpleasm', - name: 'simpleasm', - description: '汇编教学小游戏。', - url: 'https://asm.famzheng.me', - status: 'live', - }, - { - slug: 'music', - name: 'music', - description: '听歌 + 练琴 曲目管理。243 首曲库(从 oci 旧 guitar 迁过来)+ 自动抓 yopu 吉他/功能谱 + LLM 灵感推荐。', - url: 'https://music.famzheng.me', - status: 'live', - }, - { - slug: 'pyroblem', - name: 'pyroblem', - description: '详情待补。', - url: 'https://pyroblem.famzheng.me', - status: 'tbd', - }, - { - slug: 'werewolf', - name: 'werewolf', - description: '狼人杀单机发牌器。一台手机轮流传,30 个角色、4x 偏好加权、配置历史本地记忆。从 partiverse 移植。', - url: 'https://werewolf.famzheng.me', - status: 'live', - }, - { - slug: 'articulate', - name: 'articulate', - description: '中英猜词派对游戏(Articulate)。15 个主题词库 + 3 档难度 + 已看词跨场记忆。从 partiverse 移植。', - url: 'https://articulate.famzheng.me', - status: 'live', - }, - { - slug: 'karaoke', - name: 'karaoke', - description: '卡拉OK 点歌单本地管理。增删改排 + YouTube 一键搜,10 秒撤销。从 partiverse 移植。', - url: 'https://karaoke.famzheng.me', - status: 'live', - }, - { - slug: 'notes', - name: 'notes', - description: '录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + content;passphrase 鉴权。', - url: 'https://notes.famzheng.me', - status: 'live', - }, -] +export const apps: App[] = data as App[] diff --git a/apps/cube/src/chat.rs b/apps/cube/src/chat.rs index 086c12e..3b4ce1d 100644 --- a/apps/cube/src/chat.rs +++ b/apps/cube/src/chat.rs @@ -84,17 +84,56 @@ impl IntoResponse for ChatError { } } +/// apps.json 是 SSOT — 前端 apps.ts 也 import 同一份。 +const APPS_JSON: &str = include_str!("../frontend/src/apps.json"); + +#[derive(Deserialize)] +struct AppInfo { + slug: String, + description: String, + url: String, + status: String, +} + +/// 把 apps.json 渲染成 markdown bullet list 注进 system prompt。 +/// 解析失败兜底成 raw JSON(让 LLM 自己 grok),不让 chatbot 因为 ssot 坏掉而 500。 +pub fn render_apps_list() -> String { + match serde_json::from_str::>(APPS_JSON) { + Ok(apps) => apps + .iter() + .map(|a| { + format!( + "- **{slug}** ({status}) — {desc} <{url}>", + slug = a.slug, + status = a.status, + desc = a.description, + url = a.url, + ) + }) + .collect::>() + .join("\n"), + Err(_) => APPS_JSON.trim().to_string(), + } +} + pub fn system_prompt(repo: &str) -> String { format!( "你是 cube 平台(cube.famzheng.me,Fam 的小 app 平台)入口页上的聊天助手。\n\ + \n\ + 当前 cube 上线的 app 列表(status: live=可用 / pending=迁移中 / tbd=待定):\n\ + {apps}\n\ + \n\ 你可以做两件事:\n\ - 1. 回答用户关于 cube 上各个 app(werewolf / articulate / karaoke / music / simpleasm 等)的问题,简短直接\n\ - 2. 当用户表达 bug / feature request / feedback / 想反馈给 Fam 时,调用 `create_issue` 工具,\n\ - 把它整理成 issue 创建到 `{repo}`。标题要简洁明确(不超过 60 个字符),body 要包含足够上下文\n\ - (重现步骤、期望行为、用户原话等)。\n\ + 1. 回答用户关于上面这些 app 的问题,简短直接。**回答时只能基于上面列表的事实**,\n\ + 不要凭训练知识瞎猜不存在的 app 或功能。如果用户问的 app 不在列表里,直说没有。\n\ + 2. 当用户表达 bug / feature request / feedback / 想反馈给 Fam 时,调用 `create_issue`\n\ + 工具,把它整理成 issue 创建到 `{repo}`。标题简洁明确(≤60 字符),body 包含足够上下文\n\ + (涉及哪个 app / 重现步骤 / 期望行为 / 用户原话等)。\n\ \n\ 不要主动鼓励用户提 issue —— 只在他明确表达想反馈时才创建。\n\ 同一次对话只创建一个 issue。Reply 要简短,不写长段散文。", + apps = render_apps_list(), + repo = repo, ) } @@ -312,6 +351,28 @@ mod tests { assert!(p.contains("create_issue")); } + #[test] + fn render_apps_list_parses_ssot() { + let list = render_apps_list(); + // 兜底分支会返回 raw JSON(含 `{`),正常解析后是 markdown bullet(每行以 `- ` 起头) + assert!(list.starts_with("- "), "render output should be markdown bullets, got: {list}"); + // 抽查几个已知 slug 在里面 + for slug in ["cube", "werewolf", "articulate", "karaoke", "music"] { + assert!(list.contains(slug), "apps list 应该含 {slug}: {list}"); + } + } + + #[test] + fn system_prompt_includes_apps_list() { + let p = system_prompt("fam/cube"); + // 至少一个 app 的 slug 出现在 prompt 里,说明 list 真的被注进去了 + assert!(p.contains("werewolf")); + assert!(p.contains("live")); + // 防回退:旧 prompt 写死了 "werewolf / articulate / karaoke / music / simpleasm", + // 现在不再用那种枚举句式 —— 列表是数据驱动的 + assert!(!p.contains("werewolf / articulate / karaoke / music / simpleasm")); + } + #[test] fn tool_schema_shape() { let s = create_issue_tool_schema();