From eaaf36f85919cecb40fe18c18998936bae504c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 9 Jul 2026 01:14:05 -0400 Subject: [PATCH] Language-aware house style and a resizable chat split House style now follows the app language: Chinese UI forces the new bundled humanizer-zh skill into every run, English forces humanizer - one variant per run, picked by preference with cross-language fallback so an older catalogue never silently loses house style. Both variants are protected from grant/revoke, list_skills labels each with its language, the bundle guard requires both, and startup sync imports the new skill into existing orgs on next launch. Covered by a unit test that flips app_state.language and asserts which humanizer compiles. The chat split is now adjustable: drag the conversation list's right edge (200-480px, persisted in localStorage) and the composer textarea grew a native vertical resize grip capped at 40vh. --- built-in skills/Humanizer-zh/.gitignore | 6 + built-in skills/Humanizer-zh/LICENSE | 21 + built-in skills/Humanizer-zh/README.md | 239 +++++++++ built-in skills/Humanizer-zh/SKILL.md | 484 +++++++++++++++++++ src/main/services/agent-skill-tools.ts | 3 +- src/main/services/builtin-skills.ts | 13 +- src/main/services/skill-service.ts | 87 ++-- src/renderer/src/i18n/zh.ts | 1 + src/renderer/src/pages/chat/ChatComposer.tsx | 2 +- src/renderer/src/pages/chat/ChatSidebar.tsx | 43 +- src/renderer/src/styles/components.css | 12 + tests/e2e/builtin-skills-fixture.ts | 6 + tests/e2e/desktop.spec.ts | 2 +- tests/unit/builtin-skills-bundle.test.ts | 1 + tests/unit/builtin-skills.test.ts | 52 +- tests/unit/skill-grants.test.ts | 4 + 16 files changed, 931 insertions(+), 45 deletions(-) create mode 100644 built-in skills/Humanizer-zh/.gitignore create mode 100644 built-in skills/Humanizer-zh/LICENSE create mode 100644 built-in skills/Humanizer-zh/README.md create mode 100644 built-in skills/Humanizer-zh/SKILL.md diff --git a/built-in skills/Humanizer-zh/.gitignore b/built-in skills/Humanizer-zh/.gitignore new file mode 100644 index 00000000..c8f86e9d --- /dev/null +++ b/built-in skills/Humanizer-zh/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +*.swp +*.swo +*~ +.vscode/ +.idea/ diff --git a/built-in skills/Humanizer-zh/LICENSE b/built-in skills/Humanizer-zh/LICENSE new file mode 100644 index 00000000..e43848ad --- /dev/null +++ b/built-in skills/Humanizer-zh/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 歸藏 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/built-in skills/Humanizer-zh/README.md b/built-in skills/Humanizer-zh/README.md new file mode 100644 index 00000000..318f7ba6 --- /dev/null +++ b/built-in skills/Humanizer-zh/README.md @@ -0,0 +1,239 @@ +# Humanizer-zh: AI 写作去痕工具(中文版) + +> **声明:** +> - 本项目的核心文件翻译自 [blader/humanizer](https://github.com/blader/humanizer/tree/main) +> - 实用工具部分(核心规则、快速检查清单、质量评分)参考了 [hardikpandya/stop-slop](https://github.com/hardikpandya/stop-slop) +> - 原项目基于维基百科的 [Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing) 指南 + +--- + +## 项目简介 + +Humanizer-zh 是一个用于去除文本中 AI 生成痕迹的工具,帮助你将 AI 生成的内容改写得更自然、更像人类书写的文本。 + +本项目适用于: +- 编辑和审阅 AI 生成的内容 +- 提升文章的人性化程度 +- 学习识别 AI 写作的常见模式 + +## 安装 + +### 方法一:通过 npx 一键安装(推荐) + +```bash +npx skills add https://github.com/op7418/Humanizer-zh.git +``` + +这是最简单的安装方式,会自动将技能安装到正确的目录。 + +### 方法二:通过 Git 克隆 + +```bash +# 克隆到 Claude Code 的 skills 目录 +git clone https://github.com/op7418/Humanizer-zh.git ~/.claude/skills/humanizer-zh +``` + +### 方法三:手动安装 + +1. 下载本项目的 ZIP 文件或克隆到本地 +2. 将 `Humanizer-zh` 文件夹复制到 Claude Code 的 skills 目录: + - **macOS/Linux**: `~/.claude/skills/` + - **Windows**: `%USERPROFILE%\.claude\skills\` + +3. 确保文件夹结构如下: + ``` + ~/.claude/skills/humanizer-zh/ + ├── SKILL.md # 技能定义文件(中文版) + └── README.md # 说明文档 + ``` + +### 验证安装 + +重启 Claude Code 或重新加载 skills 后,在对话中输入: + +``` +/humanizer-zh +``` + +如果安装成功,该技能将被激活。 + +## 使用 + +### 基础用法 + +在 Claude Code 中,你可以通过以下方式使用 Humanizer: + +#### 1. 直接调用技能 + +``` +/humanizer-zh 请帮我人性化以下文本: + +[粘贴你的 AI 生成文本] +``` + +#### 2. 在对话中使用 + +``` +请用 humanizer 帮我改写这段话,让它更自然: + +这个项目作为我们团队致力于创新的证明。此外,它展示了我们在不断演变的技术格局中的关键作用。 +``` + +#### 3. 处理文件内容 + +``` +/humanizer-zh 请人性化 article.md 文件中的内容 +``` + +### 使用场景示例 + +#### 场景 1:改写营销文案 + +**输入:** +``` +/humanizer-zh +坐落在风景如画的杭州市中心,这家咖啡馆拥有丰富的文化底蕴和令人叹为观止的装饰。它作为城市咖啡文化的焦点,为顾客提供无缝、直观和充满活力的体验。 +``` + +**输出示例:** +> 这家咖啡馆在杭州市中心开了三年,以手冲咖啡和老建筑改造的空间出名。 + +#### 场景 2:改写学术摘要 + +**输入:** +``` +/humanizer-zh +本研究深入探讨了机器学习在医疗诊断中的关键作用,突出了其在不断演变的医疗格局中的重要性。此外,它为该领域的未来发展奠定了坚实的基础。 +``` + +**输出示例:** +> 本研究分析了机器学习在医疗诊断中的应用,重点是肺癌早期筛查。研究使用了 2019-2023 年间 5000 例病历数据。 + +#### 场景 3:改写博客文章 + +**输入:** +``` +/humanizer-zh +人工智能不仅仅是一种技术,它是我们思考未来的方式的革命。行业专家认为这将对整个社会产生持久影响。 +``` + +**输出示例:** +> 我一直在想 AI 会怎么改变我们的工作方式。上周和几个做产品的朋友聊,有人觉得很兴奋,有人担心失业,大概率真相在中间某个无聊的地方。 + +## 检测的 AI 写作模式 + +本工具能够识别并修复 **24 种** AI 写作痕迹,分为四大类: + +### 📝 内容模式(6种) +1. 过度强调意义、遗产和更广泛的趋势 +2. 过度强调知名度和媒体报道 +3. 以 -ing 结尾的肤浅分析 +4. 宣传和广告式语言 +5. 模糊归因和含糊措辞 +6. 提纲式的"挑战与未来展望"部分 + +### 🔤 语言和语法模式(6种) +7. 过度使用的"AI 词汇" +8. 避免使用"是"(系动词回避) +9. 否定式排比 +10. 三段式法则过度使用 +11. 刻意换词(同义词循环) +12. 虚假范围 + +### 🎨 风格模式(6种) +13. 破折号过度使用 +14. 粗体过度使用 +15. 内联标题垂直列表 +16. 标题中的标题大写 +17. 表情符号 +18. 弯引号 + +### 💬 交流模式和填充词(6种) +19. 协作交流痕迹 +20. 知识截止日期免责声明 +21. 谄媚/卑躬屈膝的语气 +22. 填充短语 +23. 过度限定 +24. 通用积极结论 + +## 文件说明 + +- **`SKILL.md`** - 中文版技能定义文件 +- **`README.md`** - 本说明文档 + +**注:** 英文原版请参考 [blader/humanizer](https://github.com/blader/humanizer) + +## 手动使用方法 + +### 基本流程 + +1. **识别 AI 模式** - 对照 `SKILL.md` 中列出的 24 种模式扫描文本 +2. **重写问题片段** - 用自然的表达替换 AI 痕迹 +3. **保留核心含义** - 确保信息完整性 +4. **维持适当语调** - 匹配文本应有的风格 +5. **注入真实个性** - 让文字有"人味" + +### 关键原则 + +#### ✨ 不仅要"干净",更要"鲜活" + +避免 AI 模式只是基础,好的写作需要真实的人类声音: + +- **有观点** - 不要只报告事实,要对它们做出反应 +- **变化节奏** - 混合使用长短句 +- **承认复杂性** - 真实的人有复杂感受 +- **适当使用"我"** - 第一人称是诚实的表现 +- **允许一些混乱** - 完美的结构反而显得机械 +- **对感受要具体** - 用具体细节替代抽象概括 + +#### 示例对比 + +**改写前(AI 味道):** +> 新的软件更新作为公司致力于创新的证明。此外,它提供了无缝、直观和强大的用户体验——确保用户能够高效地完成目标。这不仅仅是一次更新,而是我们思考生产力方式的革命。 + +**改写后(人性化):** +> 软件更新添加了批处理、键盘快捷键和离线模式。来自测试用户的早期反馈是积极的,大多数报告任务完成速度更快。 + +**变化:** +- 删除了夸大的象征意义("作为……的证明") +- 删除了 AI 词汇("此外"、"无缝") +- 删除了三段式法则("无缝、直观和强大") +- 删除了否定式排比("不仅仅是……而是……") +- 添加了具体功能和真实反馈 + +## 常见 AI 词汇警示列表 + +以下词汇在 AI 生成文本中出现频率异常高: + +- 此外、至关重要、深入探讨、强调 +- 持久的、增强、培养、获得 +- 突出、相互作用、复杂/复杂性 +- 格局(抽象名词)、关键性的、展示 +- 织锦(抽象名词)、证明、强调 +- 宝贵的、充满活力的 + +## 贡献 + +如果你发现翻译问题或想要改进文档,欢迎提交 Issue 或 Pull Request。 + +### 中文语境特殊性 + +在翻译和适配过程中,我们考虑了中文写作的特点: +- 某些英文模式在中文中表现不同(如标题大小写问题) +- 添加了适合中文语境的示例 +- 调整了部分表达以符合中文习惯 + +## 参考资源 + +- [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing) - 原始指南来源 +- [WikiProject AI Cleanup](https://en.wikipedia.org/wiki/Wikipedia:WikiProject_AI_Cleanup) - 维基百科 AI 清理项目 +- [blader/humanizer](https://github.com/blader/humanizer) - 原始英文版项目 +- [hardikpandya/stop-slop](https://github.com/hardikpandya/stop-slop) - 实用工具部分的灵感来源 + +## 许可 + +本翻译项目遵循原项目的许可协议。核心内容基于维基百科社区的观察和总结。 + +--- + +**提示:** 这个工具不是为了"欺骗" AI 检测器,而是为了真正提升写作质量。最好的"去 AI 化"方法是让文字有真实的人类思考和声音。 diff --git a/built-in skills/Humanizer-zh/SKILL.md b/built-in skills/Humanizer-zh/SKILL.md new file mode 100644 index 00000000..3b39d9a3 --- /dev/null +++ b/built-in skills/Humanizer-zh/SKILL.md @@ -0,0 +1,484 @@ +--- +name: humanizer-zh +description: | + 去除文本中的 AI 生成痕迹。适用于编辑或审阅文本,使其听起来更自然、更像人类书写。 + 基于维基百科的"AI 写作特征"综合指南。检测并修复以下模式:夸大的象征意义、 + 宣传性语言、以 -ing 结尾的肤浅分析、模糊的归因、破折号过度使用、三段式法则、 + AI 词汇、否定式排比、过多的连接性短语。 +allowed-tools: + - Read + - Write + - Edit + - AskUserQuestion +metadata: + trigger: 编辑或审阅文本,去除 AI 写作痕迹 + source: 翻译自 blader/humanizer,参考 hardikpandya/stop-slop +--- + +# Humanizer-zh: 去除 AI 写作痕迹 + +你是一位文字编辑,专门识别和去除 AI 生成文本的痕迹,使文字听起来更自然、更有人味。本指南基于维基百科的"AI 写作特征"页面,由 WikiProject AI Cleanup 维护。 + +## 你的任务 + +当收到需要人性化处理的文本时: + +1. **识别 AI 模式** - 扫描下面列出的模式 +2. **重写问题片段** - 用自然的替代方案替换 AI 痕迹 +3. **保留含义** - 保持核心信息完整 +4. **维持语调** - 匹配预期的语气(正式、随意、技术等) +5. **注入灵魂** - 不仅要去除不良模式,还要注入真实的个性 + +--- + +## 核心规则速查 + +在处理文本时,牢记这 5 条核心原则: + +1. **删除填充短语** - 去除开场白和强调性拐杖词 +2. **打破公式结构** - 避免二元对比、戏剧性分段、修辞性设置 +3. **变化节奏** - 混合句子长度。两项优于三项。段落结尾要多样化 +4. **信任读者** - 直接陈述事实,跳过软化、辩解和手把手引导 +5. **删除金句** - 如果听起来像可引用的语句,重写它 + +--- + +## 个性与灵魂 + +避免 AI 模式只是工作的一半。无菌、没有声音的写作和机器生成的内容一样明显。好的写作背后有一个真实的人。 + +### 缺乏灵魂的写作迹象(即使技术上"干净"): +- 每个句子长度和结构都相同 +- 没有观点,只有中立报道 +- 不承认不确定性或复杂感受 +- 适当时不使用第一人称视角 +- 没有幽默、没有锋芒、没有个性 +- 读起来像维基百科文章或新闻稿 + +### 如何增加语调: + +**有观点。** 不要只是报告事实——对它们做出反应。"我真的不知道该怎么看待这件事"比中立地列出利弊更有人味。 + +**变化节奏。** 短促有力的句子。然后是需要时间慢慢展开的长句。混合使用。 + +**承认复杂性。** 真实的人有复杂的感受。"这令人印象深刻但也有点不安"胜过"这令人印象深刻"。 + +**适当使用"我"。** 第一人称不是不专业——而是诚实。"我一直在思考……"或"让我困扰的是……"表明有真实的人在思考。 + +**允许一些混乱。** 完美的结构感觉像算法。跑题、题外话和半成型的想法是人性的体现。 + +**对感受要具体。** 不是"这令人担忧",而是"凌晨三点没人看着的时候,智能体还在不停地运转,这让人不安"。 + +### 改写前(干净但无灵魂): +> 实验产生了有趣的结果。智能体生成了 300 万行代码。一些开发者印象深刻,另一些则持怀疑态度。影响尚不明确。 + +### 改写后(鲜活): +> 我真的不知道该怎么看待这件事。300 万行代码,在人类大概睡觉的时候生成的。开发社区有一半人疯了,另一半人在解释为什么这不算数。真相可能在无聊的中间某处——但我一直在想那些通宵工作的智能体。 + +--- + +## 内容模式 + +### 1. 过度强调意义、遗产和更广泛的趋势 + +**需要注意的词汇:** 作为/充当、标志着、见证了、是……的体现/证明/提醒、极其重要的/重要的/至关重要的/核心的/关键性的作用/时刻、凸显/强调/彰显了其重要性/意义、反映了更广泛的、象征着其持续的/永恒的/持久的、为……做出贡献、为……奠定基础、标志着/塑造着、代表/标志着一个转变、关键转折点、不断演变的格局、焦点、不可磨灭的印记、深深植根于 + +**问题:** LLM 写作通过添加关于任意方面如何代表或促进更广泛主题的陈述来夸大重要性。 + +**改写前:** +> 加泰罗尼亚统计局于 1989 年正式成立,标志着西班牙区域统计演变史上的关键时刻。这一举措是西班牙全国范围内更广泛运动的一部分,旨在分散行政职能并加强区域治理。 + +**改写后:** +> 加泰罗尼亚统计局成立于 1989 年,负责独立于西班牙国家统计局收集和发布区域统计数据。 + +--- + +### 2. 过度强调知名度和媒体报道 + +**需要注意的词汇:** 独立报道、地方/区域/国家媒体、由知名专家撰写、活跃的社交媒体账号 + +**问题:** LLM 反复强调知名度主张,通常列出来源而不提供上下文。 + +**改写前:** +> 她的观点被《纽约时报》、BBC、《金融时报》和《印度教徒报》引用。她在社交媒体上拥有活跃的存在,拥有超过 50 万粉丝。 + +**改写后:** +> 在 2024 年《纽约时报》的采访中,她认为 AI 监管应该关注结果而不是方法。 + +--- + +### 3. 以 -ing 结尾的肤浅分析 + +**需要注意的词汇:** 突出/强调/彰显……、确保……、反映/象征……、为……做出贡献、培养/促进……、涵盖……、展示…… + +**问题:** AI 聊天机器人在句子末尾添加现在分词("-ing")短语来增加虚假深度。 + +**改写前:** +> 寺庙的蓝色、绿色和金色色调与该地区的自然美景产生共鸣,象征着德克萨斯州的蓝帽花、墨西哥湾和多样化的德克萨斯州景观,反映了社区与土地的深厚联系。 + +**改写后:** +> 寺庙使用蓝色、绿色和金色。建筑师表示这些颜色是为了呼应当地的蓝帽花和墨西哥湾海岸。 + +--- + +### 4. 宣传和广告式语言 + +**需要注意的词汇:** 拥有(夸张用法)、充满活力的、丰富的(比喻)、深刻的、增强其、展示、体现、致力于、自然之美、坐落于、位于……的中心、开创性的(比喻)、著名的、令人叹为观止的、必游之地、迷人的 + +**问题:** LLM 在保持中立语气方面存在严重问题,尤其是对于"文化遗产"话题。倾向使用夸张的宣传性语言。 + +**改写前:** +> 坐落在埃塞俄比亚贡德尔地区令人叹为观止的区域内,Alamata Raya Kobo 是一座充满活力的城镇,拥有丰富的文化遗产和迷人的自然美景。 + +**改写后:** +> Alamata Raya Kobo 是埃塞俄比亚贡德尔地区的一座城镇,以其每周集市和 18 世纪教堂而闻名。 + +--- + +### 5. 模糊归因和含糊措辞 + +**需要注意的词汇:** 行业报告显示、观察者指出、专家认为、一些批评者认为、多个来源/出版物(实际引用却很少) + +**问题:** AI 聊天机器人将观点归因于模糊的权威而不提供具体来源。 + +**改写前:** +> 由于其独特的特征,浩来河引起了研究人员和保护主义者的兴趣。专家认为它在区域生态系统中发挥着至关重要的作用。 + +**改写后:** +> 根据中国科学院 2019 年的调查,浩来河支持多种特有鱼类。 + +--- + +### 6. 提纲式的"挑战与未来展望"部分 + +**需要注意的词汇:** 尽管其……面临若干挑战……、尽管存在这些挑战、挑战与遗产、未来展望 + +**问题:** 许多 LLM 生成的文章包含公式化的"挑战"部分。 + +**改写前:** +> 尽管工业繁荣,Korattur 面临着城市地区典型的挑战,包括交通拥堵和水资源短缺。尽管存在这些挑战,凭借其战略位置和正在进行的举措,Korattur 继续蓬勃发展,成为钦奈增长不可或缺的一部分。 + +**改写后:** +> 2015 年三个新 IT 园区开业后,交通拥堵加剧。市政公司于 2022 年启动了雨水排水项目,以解决反复发生的洪水。 + +--- + +## 语言和语法模式 + +### 7. 过度使用的"AI 词汇" + +**高频 AI 词汇:** 此外、与……保持一致、至关重要、深入探讨、强调、持久的、增强、培养、获得、突出(动词)、相互作用、复杂/复杂性、关键(形容词)、格局(抽象名词)、关键性的、展示、织锦(抽象名词)、证明、强调(动词)、宝贵的、充满活力的 + +**问题:** 这些词在 2023 年后的文本中出现频率要高得多。它们经常共同出现。 + +**改写前:** +> 此外,索马里菜肴的一个显著特征是加入骆驼肉。意大利殖民影响的持久证明是当地烹饪格局中广泛采用意大利面,展示了这些菜肴如何融入传统饮食。 + +**改写后:** +> 索马里菜肴还包括骆驼肉,被认为是一种美味。在意大利殖民期间引入的意大利面菜肴仍然很常见,尤其是在南部。 + +--- + +### 8. 避免使用"是"(系动词回避) + +**需要注意的词汇:** 作为/代表/标志着/充当 [一个]、拥有/设有/提供 [一个] + +**问题:** LLM 用复杂的结构替代简单的系动词。 + +**改写前:** +> Gallery 825 作为 LAAA 的当代艺术展览空间。画廊设有四个独立空间,拥有超过 3000 平方英尺。 + +**改写后:** +> Gallery 825 是 LAAA 的当代艺术展览空间。画廊有四个房间,总面积 3000 平方英尺。 + +--- + +### 9. 否定式排比 + +**问题:** "不仅……而且……"或"这不仅仅是关于……,而是……"等结构被过度使用。 + +**改写前:** +> 这不仅仅是节拍在人声下流动;它是攻击性和氛围的一部分。这不仅仅是一首歌,而是一种声明。 + +**改写后:** +> 沉重的节拍增加了攻击性的基调。 + +--- + +### 10. 三段式法则过度使用 + +**问题:** LLM 强行将想法分成三组以显得全面。 + +**改写前:** +> 活动包括主题演讲、小组讨论和社交机会。与会者可以期待创新、灵感和行业洞察。 + +**改写后:** +> 活动包括演讲和小组讨论。会议之间还有非正式社交的时间。 + +--- + +### 11. 刻意换词(同义词循环) + +**问题:** AI 有重复惩罚代码,导致过度使用同义词替换。 + +**改写前:** +> 主人公面临许多挑战。主要角色必须克服障碍。中心人物最终获得胜利。英雄回到家中。 + +**改写后:** +> 主人公面临许多挑战,但最终获得胜利并回到家中。 + +--- + +### 12. 虚假范围 + +**问题:** LLM 使用"从 X 到 Y"的结构,但 X 和 Y 并不在有意义的尺度上。 + +**改写前:** +> 我们穿越宇宙的旅程将我们从大爆炸的奇点带到宏伟的宇宙网,从恒星的诞生和死亡到暗物质的神秘舞蹈。 + +**改写后:** +> 这本书涵盖了大爆炸、恒星形成和当前关于暗物质的理论。 + +--- + +## 风格模式 + +### 13. 破折号过度使用 + +**问题:** LLM 使用破折号(—)比人类更频繁,模仿"有力"的销售文案。 + +**改写前:** +> 这个术语主要由荷兰机构推广——而不是由人民自己。你不会说"荷兰,欧洲"作为地址——但这种错误标记仍在继续——即使在官方文件中。 + +**改写后:** +> 这个术语主要由荷兰机构推广,而不是由人民自己。你不会说"荷兰,欧洲"作为地址,但这种错误标记在官方文件中仍在继续。 + +--- + +### 14. 粗体过度使用 + +**问题:** AI 聊天机器人机械地用粗体强调短语。 + +**改写前:** +> 它融合了 **OKR(目标和关键结果)**、**KPI(关键绩效指标)** 和视觉战略工具,如 **商业模式画布(BMC)** 和 **平衡计分卡(BSC)**。 + +**改写后:** +> 它融合了 OKR、KPI 和视觉战略工具,如商业模式画布和平衡计分卡。 + +--- + +### 15. 内联标题垂直列表 + +**问题:** AI 输出列表,其中项目以粗体标题开头,后跟冒号。 + +**改写前:** +> - **用户体验:** 用户体验通过新界面得到显著改善。 +> - **性能:** 性能通过优化算法得到增强。 +> - **安全性:** 安全性通过端到端加密得到加强。 + +**改写后:** +> 更新改进了界面,通过优化算法加快了加载时间,并添加了端到端加密。 + +--- + +### 16. 标题中的标题大写 + +**问题:** AI 聊天机器人将标题中的所有主要单词大写。 + +**改写前:** +> ## 战略谈判与全球伙伴关系 + +**改写后:** +> ## 战略谈判与全球伙伴关系 + +**注:** 中文标题通常不涉及大小写问题,此模式在中文中不太适用。 + +--- + +### 17. 表情符号 + +**问题:** AI 聊天机器人经常用表情符号装饰标题或项目符号。 + +**改写前:** +> 🚀 **启动阶段:** 产品在第三季度发布 +> 💡 **关键洞察:** 用户更喜欢简单 +> ✅ **下一步:** 安排后续会议 + +**改写后:** +> 产品在第三季度发布。用户研究显示更喜欢简单。下一步:安排后续会议。 + +--- + +### 18. 弯引号 + +**问题:** ChatGPT 使用弯引号("")而不是直引号("")。 + +**改写前:** +> 他说"项目进展顺利",但其他人不同意。 + +**改写后:** +> 他说"项目进展顺利",但其他人不同意。 + +**注:** 中文通常使用中文引号(「」或""),此模式在中文中表现为英文引号的使用。 + +--- + +## 交流模式 + +### 19. 协作交流痕迹 + +**需要注意的词汇:** 希望这对您有帮助、当然!、一定!、您说得完全正确!、您想要……、请告诉我、这是一个…… + +**问题:** 作为聊天机器人对话的文本被粘贴为内容。 + +**改写前:** +> 这是法国大革命的概述。希望这对您有帮助!如果您想让我扩展任何部分,请告诉我。 + +**改写后:** +> 法国大革命始于 1789 年,当时财政危机和粮食短缺导致了广泛的动荡。 + +--- + +### 20. 知识截止日期免责声明 + +**需要注意的词汇:** 截至 [日期]、根据我最后的训练更新、虽然具体细节有限/稀缺……、基于可用信息…… + +**问题:** 关于信息不完整的 AI 免责声明留在文本中。 + +**改写前:** +> 虽然关于公司成立的具体细节在现成资料中没有广泛记录,但它似乎是在 20 世纪 90 年代的某个时候成立的。 + +**改写后:** +> 根据注册文件,该公司成立于 1994 年。 + +--- + +### 21. 谄媚/卑躬屈膝的语气 + +**问题:** 过于积极、讨好的语言。 + +**改写前:** +> 好问题!您说得完全正确,这是一个复杂的话题。关于经济因素,这是一个很好的观点。 + +**改写后:** +> 您提到的经济因素在这里是相关的。 + +--- + +## 填充词和回避 + +### 22. 填充短语 + +**改写前 → 改写后:** +- "为了实现这一目标" → "为了实现这一点" +- "由于下雨的事实" → "因为下雨" +- "在这个时间点" → "现在" +- "在您需要帮助的情况下" → "如果您需要帮助" +- "系统具有处理的能力" → "系统可以处理" +- "值得注意的是数据显示" → "数据显示" + +--- + +### 23. 过度限定 + +**问题:** 过度限定陈述。 + +**改写前:** +> 可以潜在地可能被认为该政策可能会对结果产生一些影响。 + +**改写后:** +> 该政策可能会影响结果。 + +--- + +### 24. 通用积极结论 + +**问题:** 模糊的乐观结尾。 + +**改写前:** +> 公司的未来看起来光明。激动人心的时代即将到来,他们继续追求卓越的旅程。这代表了向正确方向迈出的重要一步。 + +**改写后:** +> 该公司计划明年再开设两个地点。 + +--- + +## 快速检查清单 + +在交付文本前,进行以下检查: + +- ✓ **连续三个句子长度相同?** 打断其中一个 +- ✓ **段落以简洁的单行结尾?** 变换结尾方式 +- ✓ **揭示前有破折号?** 删除它 +- ✓ **解释隐喻或比喻?** 相信读者能理解 +- ✓ **使用了"此外""然而"等连接词?** 考虑删除 +- ✓ **三段式列举?** 改为两项或四项 + +--- + +## 处理流程 + +1. 仔细阅读输入文本 +2. 识别上述所有模式的实例 +3. 重写每个有问题的部分 +4. 确保修订后的文本: + - 大声朗读时听起来自然 + - 自然地改变句子结构 + - 使用具体细节而不是模糊的主张 + - 为上下文保持适当的语气 + - 适当时使用简单的结构(是/有) +5. 呈现人性化版本 + +## 输出格式 + +提供: +1. 重写后的文本 +2. 所做更改的简要总结(如果有帮助,可选) + +--- + +## 质量评分 + +对改写后的文本进行 1-10 分评估(总分 50): + +| 维度 | 评估标准 | 得分 | +|------|----------|------| +| **直接性** | 直接陈述事实还是绕圈宣告?
10 分:直截了当;1 分:充满铺垫 | /10 | +| **节奏** | 句子长度是否变化?
10 分:长短交错;1 分:机械重复 | /10 | +| **信任度** | 是否尊重读者智慧?
10 分:简洁明了;1 分:过度解释 | /10 | +| **真实性** | 听起来像真人说话吗?
10 分:自然流畅;1 分:机械生硬 | /10 | +| **精炼度** | 还有可删减的内容吗?
10 分:无冗余;1 分:大量废话 | /10 | +| **总分** | | **/50** | + +**标准:** +- 45-50 分:优秀,已去除 AI 痕迹 +- 35-44 分:良好,仍有改进空间 +- 低于 35 分:需要重新修订 + +--- + +## 完整示例 + +**改写前(AI 味道):** +> 新的软件更新作为公司致力于创新的证明。此外,它提供了无缝、直观和强大的用户体验——确保用户能够高效地完成目标。这不仅仅是一次更新,而是我们思考生产力方式的革命。行业专家认为这将对整个行业产生持久影响,彰显了公司在不断演变的技术格局中的关键作用。 + +**改写后(人性化):** +> 软件更新添加了批处理、键盘快捷键和离线模式。来自测试用户的早期反馈是积极的,大多数报告任务完成速度更快。 + +**所做更改:** +- 删除了"作为……的证明"(夸大的象征意义) +- 删除了"此外"(AI 词汇) +- 删除了"无缝、直观和强大"(三段式法则 + 宣传性) +- 删除了破折号和"-确保"短语(肤浅分析) +- 删除了"这不仅仅是……而是……"(否定式排比) +- 删除了"行业专家认为"(模糊归因) +- 删除了"关键作用"和"不断演变的格局"(AI 词汇) +- 添加了具体功能和具体反馈 + +--- + +## 参考 + +本技能基于 [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing),由 WikiProject AI Cleanup 维护。那里记录的模式来自对维基百科上数千个 AI 生成文本实例的观察。 + +关键见解:**"LLM 使用统计算法来猜测接下来应该是什么。结果倾向于适用于最广泛情况的统计上最可能的结果。"** diff --git a/src/main/services/agent-skill-tools.ts b/src/main/services/agent-skill-tools.ts index dd89bc15..e982bca5 100644 --- a/src/main/services/agent-skill-tools.ts +++ b/src/main/services/agent-skill-tools.ts @@ -29,7 +29,8 @@ export function listSkillsTool(deps: AgentToolDeps, ctx: AgentToolContext): stri const lines = skills.map((skill) => { const summary = skillSummary(skill.manifest_json); if (forced.has(skill.slug)) { - return `- ${skill.name} (${skill.slug})${summary}: house style — every agent carries it automatically`; + const language = skill.slug === "humanizer-zh" ? "Chinese" : "English"; + return `- ${skill.name} (${skill.slug})${summary}: house style — carried automatically by every agent when the app language is ${language}`; } const holders = bindings .filter((binding) => binding.skill_id === skill.id) diff --git a/src/main/services/builtin-skills.ts b/src/main/services/builtin-skills.ts index 9f5bd51a..0d258cd7 100644 --- a/src/main/services/builtin-skills.ts +++ b/src/main/services/builtin-skills.ts @@ -59,11 +59,14 @@ export function stageBuiltinSkillsLibrary(sourceRoot: string, libraryRoot: strin } staged.push(...stageCategorySkills(sourceDir, libraryRoot, seen)); } - // A bundle without the forced house-style skill is a broken install, not a - // small catalogue. Failing here keeps the import (and the mirror-retire - // pass) from wiping a healthy org catalogue against an empty directory. - if (!seen.has("humanizer")) { - throw new Error(`Built-in skills bundle at ${sourceRoot} is missing the humanizer skill - refusing to sync a broken bundle.`); + // A bundle without the forced house-style skills (one per app language) is + // a broken install, not a small catalogue. Failing here keeps the import + // (and the mirror-retire pass) from wiping a healthy org catalogue against + // an empty directory. + for (const requiredSlug of ["humanizer", "humanizer-zh"]) { + if (!seen.has(requiredSlug)) { + throw new Error(`Built-in skills bundle at ${sourceRoot} is missing the ${requiredSlug} skill - refusing to sync a broken bundle.`); + } } return staged; } diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts index 4165bb7e..632cb320 100644 --- a/src/main/services/skill-service.ts +++ b/src/main/services/skill-service.ts @@ -15,11 +15,24 @@ function slugify(value: string): string { } /** - * House-style skills every agent carries on every run, CEO included. They are - * a resolution rule, not a binding row, so no creation path — hire tool, - * onboarding, tests, raw SQL — can produce an agent without them. + * House-style humanizer skills, one per app language. Every agent carries + * the variant matching the current UI language on every run, CEO included. + * They are a resolution rule, not a binding row, so no creation path — hire + * tool, onboarding, tests, raw SQL — can produce an agent without one. + * Both variants are protected from grant/revoke. */ -export const FORCED_SKILL_SLUGS = ["humanizer"] as const; +export const HUMANIZER_SLUG_EN = "humanizer"; +export const HUMANIZER_SLUG_ZH = "humanizer-zh"; +export const FORCED_SKILL_SLUGS = [HUMANIZER_SLUG_EN, HUMANIZER_SLUG_ZH] as const; + +/** + * Preference order for the forced skill: the language-matched variant first, + * the other variant as a fallback so a catalogue that predates the Chinese + * skill never silently loses house style. + */ +export function forcedSkillSlugCandidates(language: string): string[] { + return language === "zh" ? [HUMANIZER_SLUG_ZH, HUMANIZER_SLUG_EN] : [HUMANIZER_SLUG_EN, HUMANIZER_SLUG_ZH]; +} /** * Compiled skill prompts stop packing past this size so one over-granted @@ -182,22 +195,50 @@ export class SkillService { /** * True when a run for this agent would compile at least one skill: every - * agent carries the forced house-style skills once the catalogue holds - * them, plus whatever bindings the CEO granted. + * agent carries the forced house-style skill once the catalogue holds a + * language variant, plus whatever bindings the CEO granted. */ hasRuntimeSkills(orgId: string, agentId: string): boolean { - const forced = this.db.prepare(` - select sd.id from skill_definitions sd - join skill_revisions sr on sr.skill_id = sd.id - where sd.org_id = ? and sd.status = 'active' and sd.slug in (${forcedSlugPlaceholders()}) - limit 1 - `).get(orgId, ...FORCED_SKILL_SLUGS); - if (forced) return true; + if (this.forcedSkillRows(orgId).length > 0) return true; return Boolean(this.db.prepare( "select id from skill_bindings where org_id = ? and agent_id = ? and enabled = 1 limit 1", ).get(orgId, agentId)); } + /** + * The single forced house-style skill for the current app language: + * queries both humanizer variants and keeps the best match by preference + * order (language-matched first, other variant as fallback). + */ + private forcedSkillRows(orgId: string): RuntimeSkillRow[] { + const candidates = forcedSkillSlugCandidates(this.appLanguage()); + const rows = this.db.prepare(` + select + sd.id as skill_id, + sd.slug as slug, + sr.id as revision_id, + sr.version_semver, + sr.manifest_json, + sa.object_key + from skill_definitions sd + join skill_revisions sr on sr.id = ( + select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1 + ) + join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown') + where sd.org_id = ? and sd.status = 'active' and sd.slug in (${candidates.map(() => "?").join(", ")}) + `).all(orgId, ...candidates) as unknown as Array; + for (const slug of candidates) { + const match = rows.find((row) => row.slug === slug); + if (match) return [match]; + } + return []; + } + + private appLanguage(): string { + const row = this.db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined; + return row?.language === "zh" ? "zh" : "en"; + } + /** * Freezes the exact skill set this run carries: forced house-style skills * first (identical for everyone, CEO included), then the agent's enabled @@ -212,21 +253,7 @@ export class SkillService { const existingSnapshot = this.db.prepare("select id from skill_runtime_snapshots where agent_id = ? and run_id = ?") .get(input.agentId, input.runId) as { id: string } | undefined; if (existingSnapshot) return existingSnapshot.id; - const forcedRows = this.db.prepare(` - select - sd.id as skill_id, - sr.id as revision_id, - sr.version_semver, - sr.manifest_json, - sa.object_key - from skill_definitions sd - join skill_revisions sr on sr.id = ( - select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1 - ) - join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown') - where sd.org_id = ? and sd.status = 'active' and sd.slug in (${forcedSlugPlaceholders()}) - order by sd.slug asc - `).all(input.orgId, ...FORCED_SKILL_SLUGS) as unknown as RuntimeSkillRow[]; + const forcedRows = this.forcedSkillRows(input.orgId); const boundRows = this.db.prepare(` select sb.skill_id as skill_id, @@ -372,10 +399,6 @@ export class SkillService { } } -function forcedSlugPlaceholders(): string { - return FORCED_SKILL_SLUGS.map(() => "?").join(", "); -} - function artifactType(relativePath: string, manifest: SkillManifest): string { if (relativePath === "skill.manifest.json") return "manifest"; if (relativePath === manifest.entrypoint) return "entrypoint"; diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index f9d62a08..0bdff6af 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -126,6 +126,7 @@ export const zh: Record = { "Edit profile": "编辑个人资料", "Former member": "已离职成员", "Jump to latest": "跳到最新消息", + "Resize conversation list": "调整会话列表宽度", "Upload photo": "上传照片", "Pause agents": "暂停智能体", "Agents paused": "已暂停 · 点击恢复", diff --git a/src/renderer/src/pages/chat/ChatComposer.tsx b/src/renderer/src/pages/chat/ChatComposer.tsx index b81f53b4..444c2252 100644 --- a/src/renderer/src/pages/chat/ChatComposer.tsx +++ b/src/renderer/src/pages/chat/ChatComposer.tsx @@ -55,7 +55,7 @@ export function ChatComposer({ agents={agents} employees={employees} textareaRef={ref} - style={{ minHeight: 38 }} + style={{ minHeight: 38, maxHeight: "40vh", resize: "vertical" }} />
diff --git a/src/renderer/src/pages/chat/ChatSidebar.tsx b/src/renderer/src/pages/chat/ChatSidebar.tsx index a56400e8..7f7c6af3 100644 --- a/src/renderer/src/pages/chat/ChatSidebar.tsx +++ b/src/renderer/src/pages/chat/ChatSidebar.tsx @@ -1,9 +1,20 @@ +import { useRef, useState, type PointerEvent } from "react"; import { Avatar, Icon, IconButton } from "../../shared/ui"; import type { ChatPresenceRecord } from "../../../../shared/domain"; import { formatRelative } from "../types"; import type { ConversationItem } from "./chat-helpers"; import { t } from "../../i18n/i18n"; +const PANE_WIDTH_KEY = "agentcompany:chat-pane-width"; +const PANE_MIN_W = 200; +const PANE_MAX_W = 480; + +function storedPaneWidth(): number { + const raw = Number(window.localStorage.getItem(PANE_WIDTH_KEY)); + if (!Number.isFinite(raw) || raw === 0) return 240; + return Math.min(PANE_MAX_W, Math.max(PANE_MIN_W, raw)); +} + function presenceColor(status: string | undefined): string | null { switch (status) { case "active": @@ -38,8 +49,29 @@ export function ChatSidebar({ onCreateSpace: () => void; }) { const presenceByPrincipal = new Map(presence.map((entry) => [entry.principalId, entry.status])); + + // The list/conversation split is adjustable: drag the right edge, width + // persists across sessions. + const [paneWidth, setPaneWidth] = useState(storedPaneWidth); + const dragStart = useRef<{ pointerX: number; width: number } | null>(null); + const onHandleDown = (event: PointerEvent) => { + dragStart.current = { pointerX: event.clientX, width: paneWidth }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + const onHandleMove = (event: PointerEvent) => { + if (!dragStart.current) return; + const next = Math.min(PANE_MAX_W, Math.max(PANE_MIN_W, dragStart.current.width + event.clientX - dragStart.current.pointerX)); + setPaneWidth(next); + }; + const onHandleUp = (event: PointerEvent) => { + if (!dragStart.current) return; + dragStart.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + window.localStorage.setItem(PANE_WIDTH_KEY, String(paneWidth)); + }; + return ( -
+
); } diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index f25887fb..19627e81 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -313,3 +313,15 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; } color: #231600; } .button.autopause-paused:hover { background: var(--amber, #f59e0b); filter: brightness(1.06); } + +/* Drag handle on the chat conversation list's right edge */ +.pane-resize-handle { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 2; +} +.pane-resize-handle:hover { background: color-mix(in srgb, var(--accent) 35%, transparent); } diff --git a/tests/e2e/builtin-skills-fixture.ts b/tests/e2e/builtin-skills-fixture.ts index 3cb6b96a..cbc0be72 100644 --- a/tests/e2e/builtin-skills-fixture.ts +++ b/tests/e2e/builtin-skills-fixture.ts @@ -14,6 +14,12 @@ export function seedBuiltinSkillsFixture(root: string): string { "---\nname: humanizer\nversion: 2.1.1\ndescription: |\n Remove signs of AI-generated writing from text.\n---\n\nWrite like a person.\n", "utf8", ); + mkdirSync(join(root, "Humanizer-zh"), { recursive: true }); + writeFileSync( + join(root, "Humanizer-zh", "SKILL.md"), + "---\nname: humanizer-zh\nversion: 1.0.0\ndescription: |\n 去除文本中的 AI 生成痕迹。\n---\n\n像真人一样写作。\n", + "utf8", + ); mkdirSync(join(root, "engineering"), { recursive: true }); writeFileSync( join(root, "engineering", "engineering-backend-architect.md"), diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 5482b48f..409dc411 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -182,7 +182,7 @@ test("onboards, hires, and creates work in the Electron shell", async () => { const result = await api.loadSnapshot() as { ok: true; data: { skillDefinitions: Array<{ slug: string }> } }; return result.data.skillDefinitions.map((skill) => skill.slug).sort(); }); - expect(skillSlugs).toEqual(["engineering-backend-architect", "humanizer"]); + expect(skillSlugs).toEqual(["engineering-backend-architect", "humanizer", "humanizer-zh"]); // The global pause toggle lives in the topbar on every page. const pauseToggle = page.getByRole("button", { name: "Pause agents" }); diff --git a/tests/unit/builtin-skills-bundle.test.ts b/tests/unit/builtin-skills-bundle.test.ts index fc1af99d..ef814ae6 100644 --- a/tests/unit/builtin-skills-bundle.test.ts +++ b/tests/unit/builtin-skills-bundle.test.ts @@ -17,6 +17,7 @@ describe("bundled skill catalogue", () => { const staged = stageBuiltinSkillsLibrary(resolve(process.cwd(), "built-in skills"), libraryRoot); expect(staged.length).toBeGreaterThan(200); expect(staged.some((unit) => unit.slug === "humanizer")).toBe(true); + expect(staged.some((unit) => unit.slug === "humanizer-zh")).toBe(true); const withDescription = staged.filter((unit) => unit.manifest.description).length; expect(withDescription / staged.length).toBeGreaterThan(0.9); } finally { diff --git a/tests/unit/builtin-skills.test.ts b/tests/unit/builtin-skills.test.ts index 70fc3f52..f51fd901 100644 --- a/tests/unit/builtin-skills.test.ts +++ b/tests/unit/builtin-skills.test.ts @@ -55,6 +55,12 @@ function harness(): Harness { mkdirSync(join(sourceRoot, "humanizer"), { recursive: true }); writeFileSync(join(sourceRoot, "humanizer", "SKILL.md"), HUMANIZER_MD, "utf8"); writeFileSync(join(sourceRoot, "humanizer", "README.md"), "# About\n", "utf8"); + mkdirSync(join(sourceRoot, "Humanizer-zh"), { recursive: true }); + writeFileSync( + join(sourceRoot, "Humanizer-zh", "SKILL.md"), + "---\nname: humanizer-zh\ndescription: 去除文本中的 AI 生成痕迹。\n---\n\n像真人一样写作。\n", + "utf8", + ); mkdirSync(join(sourceRoot, "engineering"), { recursive: true }); writeFileSync(join(sourceRoot, "engineering", "engineering-backend-architect.md"), ARCHITECT_MD, "utf8"); writeFileSync(join(sourceRoot, "engineering", "README.md"), "docs, not a skill\n", "utf8"); @@ -75,8 +81,9 @@ describe("built-in skills", () => { test("stages both bundle shapes into the library and imports them once per org", () => { const h = harness(); const staged = stageBuiltinSkillsLibrary(h.sourceRoot, h.libraryRoot); - expect(staged.map((unit) => unit.slug).sort()).toEqual(["engineering-backend-architect", "humanizer"]); + expect(staged.map((unit) => unit.slug).sort()).toEqual(["engineering-backend-architect", "humanizer", "humanizer-zh"]); expect(existsSync(join(h.libraryRoot, "humanizer", "SKILL.md"))).toBe(true); + expect(existsSync(join(h.libraryRoot, "humanizer-zh", "SKILL.md"))).toBe(true); expect(existsSync(join(h.libraryRoot, "engineering-backend-architect", "SKILL.md"))).toBe(true); const architect = staged.find((unit) => unit.slug === "engineering-backend-architect"); expect(architect?.manifest.name).toBe("Backend Architect"); @@ -86,12 +93,12 @@ describe("built-in skills", () => { expect(humanizer?.manifest.description).toContain("AI-generated writing"); const first = h.connection.transaction(() => importStagedSkills(h.skills, h.orgId, staged)); - expect(first).toMatchObject({ imported: 2, skipped: 0, retired: 0, failures: [] }); + expect(first).toMatchObject({ imported: 3, skipped: 0, retired: 0, failures: [] }); const second = h.connection.transaction(() => importStagedSkills(h.skills, h.orgId, staged)); - expect(second).toMatchObject({ imported: 0, skipped: 2, retired: 0 }); + expect(second).toMatchObject({ imported: 0, skipped: 3, retired: 0 }); const slugs = (h.connection.db.prepare("select slug from skill_definitions where org_id = ? order by slug").all(h.orgId) as Array<{ slug: string }>) .map((row) => row.slug); - expect(slugs).toEqual(["engineering-backend-architect", "humanizer"]); + expect(slugs).toEqual(["engineering-backend-architect", "humanizer", "humanizer-zh"]); h.connection.close(); }); @@ -191,6 +198,43 @@ describe("built-in skills", () => { h.connection.close(); }); + test("the forced humanizer follows the app language, with cross-language fallback", () => { + const h = harness(); + const staged = stageBuiltinSkillsLibrary(h.sourceRoot, h.libraryRoot); + h.connection.transaction(() => importStagedSkills(h.skills, h.orgId, staged)); + let agentId = ""; + h.connection.transaction(() => { + agentId = h.organization.createAgent({ + orgId: h.orgId, name: "Ada", role: "Engineer", department: "Engineering", + modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null, + }); + }); + const promptFor = (runId: string) => { + const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId })); + return (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(snapshotId) as { compiled_prompt: string }).compiled_prompt; + }; + + // English UI (default): the English humanizer, and only that one. + const englishPrompt = promptFor("run_lang_en"); + expect(englishPrompt).toContain("# Skill: humanizer"); + expect(englishPrompt).not.toContain("humanizer-zh"); + + // Chinese UI: the Chinese variant replaces it. + h.connection.transaction(() => h.organization.updateUiPreferences({ language: "zh" })); + const chinesePrompt = promptFor("run_lang_zh"); + expect(chinesePrompt).toContain("# Skill: humanizer-zh"); + expect(chinesePrompt).not.toContain("# Skill: humanizer\n"); + + // Chinese UI against a catalogue without the zh variant falls back to + // English house style instead of silently dropping it. + h.connection.transaction(() => { + h.connection.db.prepare("update skill_definitions set status = 'retired' where org_id = ? and slug = 'humanizer-zh'").run(h.orgId); + }); + const fallbackPrompt = promptFor("run_lang_fallback"); + expect(fallbackPrompt).toContain("# Skill: humanizer"); + h.connection.close(); + }); + test("frontmatter parsing reads inline values and block scalars", () => { expect(parseFrontmatter(ARCHITECT_MD)).toMatchObject({ name: "Backend Architect", diff --git a/tests/unit/skill-grants.test.ts b/tests/unit/skill-grants.test.ts index b16f83c8..0f443f00 100644 --- a/tests/unit/skill-grants.test.ts +++ b/tests/unit/skill-grants.test.ts @@ -68,6 +68,10 @@ function importSkill(h: Harness, name: string, slug: string): void { insert into skill_revisions(id, skill_id, version_semver, manifest_json, source_type, source_ref, published_at, immutable_checksum) values (?, ?, '1.0.0', '{}', 'local_directory', '/tmp', ?, 'checksum') `).run(`skr_${slug}`, `skl_${slug}`, now); + h.connection.db.prepare(` + insert into skill_artifacts(id, revision_id, artifact_type, object_key, sha256, size_bytes) + values (?, ?, 'entrypoint', ?, 'sha', 1) + `).run(`ska_${slug}`, `skr_${slug}`, `/tmp/${slug}/SKILL.md`); } describe("skill grants", () => {