From a7bee7f3b6653ae77c2eb27874c9217ec7b70cfd Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Mar 2026 16:48:06 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(ai-entry):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96AI=E5=8A=A9=E6=89=8B=E8=B4=B4=E8=BE=B9=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 AI 助手入口从侧栏工具区迁移为主内容区右侧贴边标签 - 调整打开态贴边标签锚点到面板左外沿,避免遮挡头部操作区 - 重排侧栏顶部工具布局,恢复四项按钮的稳定网格结构 - 新增 aiEntryLayout 布局辅助与回归测试,覆盖打开态附着位置 --- .../plans/2026-03-28-ai-edge-handle-entry.md | 87 ++++++++++ .../2026-03-28-ai-edge-handle-entry-design.md | 158 ++++++++++++++++++ ...度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md | 63 +++++++ frontend/src/App.tsx | 102 ++++++++--- frontend/src/utils/aiEntryLayout.test.ts | 71 ++++++++ frontend/src/utils/aiEntryLayout.ts | 59 +++++++ 6 files changed, 520 insertions(+), 20 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md create mode 100644 docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md create mode 100644 docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md create mode 100644 frontend/src/utils/aiEntryLayout.test.ts create mode 100644 frontend/src/utils/aiEntryLayout.ts diff --git a/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md b/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md new file mode 100644 index 0000000..dd4c510 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md @@ -0,0 +1,87 @@ +# AI Edge Handle Entry Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 AI 助手入口从标题栏移除,改为主内容区右侧的 `A2` 贴边标签入口,并保持现有 AI 面板开关与聊天逻辑不变。 + +**Architecture:** 保留现有 `App.tsx` 作为 AI 入口装配点,不改 AI 面板展开方向和 store 状态。通过 `aiEntryLayout.ts` 承载可测试的布局/样式决策,让 `App.tsx` 只负责在“主内容关闭态”和“面板展开态”两个壳层里挂载同一个贴边标签。 + +**Tech Stack:** React 18, TypeScript, Ant Design, Vitest, Vite + +--- + +## File Map + +- Modify: `frontend/src/App.tsx` + - 移除标题栏 AI 入口渲染与相关样式状态 + - 在主内容区右边缘挂载关闭态入口 + - 在 AI 面板外沿挂载打开态入口 +- Modify: `frontend/src/utils/aiEntryLayout.ts` + - 将现有标题栏专用布局 helper 改为右侧贴边标签 helper + - 保留 `SIDEBAR_UTILITY_ITEM_KEYS` +- Modify: `frontend/src/utils/aiEntryLayout.test.ts` + - 先写失败测试,锁定入口位置、附着位置和贴边标签样式约束 +- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md` + - 同步“设计已切换到右侧贴边标签方案”和后续验证状态 + +## Chunk 1: Layout Contract Regression + +### Task 1: 把布局辅助契约改成“右侧贴边标签” + +**Files:** +- Modify: `frontend/src/utils/aiEntryLayout.test.ts` +- Modify: `frontend/src/utils/aiEntryLayout.ts` + +- [ ] **Step 1: 写失败测试,锁定新的布局语义** +- [ ] **Step 2: 运行测试,确认它因旧契约而失败** +- [ ] **Step 3: 用最小改动实现新的 helper 契约** +- [ ] **Step 4: 重新运行测试,确认 helper 契约变绿** +- [ ] **Step 5: 提交这一小块** + +## Chunk 2: Remove Titlebar Entry + +### Task 2: 从标题栏彻底移除 AI 入口 + +**Files:** +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: 写出本次要删除的标题栏入口代码清单** +- [ ] **Step 2: 删除标题栏 AI 入口相关状态与 JSX** +- [ ] **Step 3: 运行前端构建,确认清理后没有遗留引用** +- [ ] **Step 4: 提交标题栏清理** + +## Chunk 3: Mount The Edge Handle + +### Task 3: 在主内容区右边缘挂载关闭态贴边标签 + +**Files:** +- Modify: `frontend/src/App.tsx` +- Modify: `frontend/src/utils/aiEntryLayout.ts` + +- [ ] **Step 1: 为关闭态入口写最小布局决策代码** +- [ ] **Step 2: 把主内容横向容器改成可承载绝对定位标签** +- [ ] **Step 3: 运行构建,确认关闭态入口接入不破坏布局** +- [ ] **Step 4: 提交关闭态入口挂载** + +### Task 4: 打开 AI 面板时让标签贴住面板外沿 + +**Files:** +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: 给 AI 面板外层加一个仅负责贴边标签定位的壳层** +- [ ] **Step 2: 用附着位置决策保证开关态只渲染一个入口** +- [ ] **Step 3: 重新运行 helper 测试和构建** +- [ ] **Step 4: 提交打开态连续关系** + +## Chunk 4: Manual Verification And Docs + +### Task 5: 手工验证空间关系,再更新追踪文档 + +**Files:** +- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md` + +- [ ] **Step 1: 启动本地开发环境做手工验证** +- [ ] **Step 2: 如发现重叠,优先调整偏移而不是改回胶囊按钮** +- [ ] **Step 3: 运行最终验证命令** +- [ ] **Step 4: 更新需求追踪文档** +- [ ] **Step 5: 提交收尾文档与验证结果** diff --git a/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md b/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md new file mode 100644 index 0000000..c49bf2b --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md @@ -0,0 +1,158 @@ +# AI 助手入口重设计:右侧贴边标签方案 + +## 1. 背景 + +当前 AI 助手入口已经尝试过两种方案: + +1. 标题栏右侧、靠近窗口控制按钮的胶囊按钮 +2. 标题栏左侧、紧跟应用标题的胶囊按钮 + +这两种方案都存在同一个问题:入口仍然被感知为“标题栏按钮”或“全局工具按钮”,而不是“从右侧展开的协作面板入口”。用户已经明确选择将 AI 助手定义为协作入口,而非标题栏功能按钮或左侧工作流工具。 + +本次设计目标是重新定义 AI 助手入口的空间归属关系,让入口与右侧 AI 面板形成直接、自然的视觉和交互映射。 + +## 2. 设计目标 + +- 让 AI 入口看起来像右侧面板的入口,而不是标题栏按钮 +- 让入口位置与 AI 面板展开方向保持一致 +- 降低入口对标题栏和主工作区视觉层级的干扰 +- 保持现有 AI 面板开关逻辑、面板位置、聊天状态和快捷键不变 +- 保持左侧 `Sider` 顶部工具组为四个常规工具入口,不再放回 AI + +## 3. 非目标 + +- 不修改 AI 面板本体的信息架构 +- 不修改 AI 聊天、会话、模型、上下文逻辑 +- 不新增 AI 专属状态存储字段 +- 不重新设计标题栏 +- 不把 AI 面板改成左侧抽屉或底部抽屉 + +## 4. 已评估方案 + +### 方案 A:标题栏入口 + +把 AI 放在标题栏右侧或标题后方。 + +结论:放弃。 + +原因: + +- 容易被识别成窗口级按钮,而不是右侧协作面板入口 +- 入口与 AI 面板的空间关系过远 +- 标题栏承担拖拽、双击、窗口控制等系统职责,额外入口容易显得突兀 + +### 方案 B:内容区悬浮胶囊 + +把 AI 入口放在内容区右上角,作为脱离标题栏的悬浮按钮。 + +结论:未采用。 + +原因: + +- 比标题栏好,但仍然像“漂浮的按钮” +- 视觉上还是偏独立动作,而不是面板把手 +- 容易与未来内容区工具条或右上角操作区冲突 + +### 方案 C:右侧贴边入口 + +把 AI 入口挂在主内容区右边缘、贴近 AI 面板出现位置。 + +结论:采用。 + +原因: + +- 最符合“右侧面板从这里展开”的直觉 +- 可以脱离标题栏和窗口控制区 +- 与 AI 面板的视觉关系最强 +- 更符合“协作入口”而不是“工具按钮”的产品定位 + +## 5. 最终方案 + +### 5.1 位置 + +- AI 入口彻底离开标题栏 +- AI 入口不再出现在左侧 `Sider` 顶部工具组 +- 入口挂载在主内容区容器内部,而不是窗口根节点 +- 位置锚点是主内容区最右侧边缘,即现有 AI 面板展开出来的那条边 +- 默认位于内容区右上方,明显低于标题栏底边,避免再与标题栏形成视觉混淆 + +### 5.2 形态 + +选定形态为 `A2:上角贴边标签`。 + +入口外观定义如下: + +- 采用“半嵌入式贴边标签”而不是完整胶囊按钮 +- 标签右侧贴边,左侧圆角外露 +- 默认文案收敛为 `AI` +- 保留一个轻量机器人图标,用于快速识别 +- 视觉风格使用面板边框色、弱背景和轻圆角 +- 不使用强阴影、不使用主按钮视觉、不做明显浮层感 + +目标效果是:入口更像右侧协作面板的标签,而不是单独悬浮的交互控件。 + +### 5.3 状态 + +关闭态: + +- 标签保持低存在感 +- 弱背景、弱边框、常规文字色 +- 保证可发现,但不抢主内容注意力 + +悬停态: + +- 仅做轻微提亮 +- 允许边框轻微增强 +- 不放大、不上浮、不出现明显浮层阴影 + +打开态: + +- 标签切换到 AI 激活色 +- 视觉上与右侧 AI 面板顶部外沿形成连续感 +- 不隐藏入口,让用户明确知道“标签已经展开成右侧这块面板” + +## 6. 交互定义 + +- 点击贴边标签时,仍然调用现有 AI 面板开关逻辑 +- AI 面板继续从右侧展开,不改变方向 +- 关闭面板后,标签恢复为单独的贴边入口 +- 打开面板时,标签保留在面板外沿作为“已展开标识” +- 当面板宽度被拖动时,标签始终贴在面板外沿,不与主内容区脱节 +- 在窄窗口下优先保证标签可点击,必要时继续保留 `AI` 简写,不要求展开成长文案 + +## 7. 实现边界 + +- 主入口显示与定位由 `App.tsx` 管理 +- AI 面板本身只保留现有展开、关闭和宽度调整行为 +- 状态继续复用现有 `aiPanelVisible`、`toggleAIPanel`、`setAIPanelVisible` +- 不新增持久化字段,不改 store 结构 +- 左侧 `Sider` 顶部继续保留四个工具按钮:`工具 / 代理 / 主题 / 关于` +- 标题栏恢复为纯窗口级区域,不继续承载 AI 入口 + +## 8. 验证口径 + +实现后重点验证以下事项: + +1. 关闭态时,右侧贴边标签是否自然,不像悬浮按钮 +2. 打开态时,标签是否与 AI 面板形成清晰连续关系 +3. 拖动 AI 面板宽度后,标签是否仍贴在面板外沿 +4. 窄窗口下标签是否仍可点击,不与内容区遮挡 +5. 标题栏空白区、窗口控制按钮、左侧侧栏工具组是否恢复干净,不再承担 AI 入口职责 + +## 9. 风险与注意事项 + +- 贴边标签如果做得过大,会重新变成“漂浮按钮” +- 贴边标签如果做得过窄,会影响关闭态可发现性 +- 标签定位需要明确绑定主内容区右边界,而不是整个窗口最右边界,否则在不同布局状态下容易错位 +- 打开态与面板外沿的连续关系必须处理好,否则会显得像两个无关控件 + +## 10. 决策结论 + +本次 AI 助手入口重设计的最终结论是: + +- 不再使用标题栏入口 +- 不回退到左侧工具条入口 +- 采用主内容区右侧的贴边标签方案 +- 采用 `A2` 形态,即“上角贴边标签” + +这是当前最符合“协作入口”定位的方案,也是最能解释 AI 面板空间来源的方案。 diff --git a/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md b/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md new file mode 100644 index 0000000..b5261ee --- /dev/null +++ b/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md @@ -0,0 +1,63 @@ +# 需求进度追踪 - AI助手入口迁移到主内容区右侧贴边标签 + +## 1. 需求摘要 +- 需求名称:AI 助手入口迁移到主内容区右侧贴边标签 +- 提出日期:2026-03-28 +- 负责人:Codex +- 目标:将 AI 助手入口从左侧 `Sider` 顶部工具条和标题栏中移出,改为主内容区右侧边缘的 `A2` 贴边标签入口,并保持现有 AI 面板开关逻辑不变。 +- 非目标:不调整 AI 面板展开方向,不修改 AI 聊天逻辑,不新增快捷键或状态存储。 + +## 2. 范围与验收 +- 范围:`App.tsx` 主内容区与 AI 面板装配位置、左侧 `Sider` 顶部工具条、`aiEntryLayout` 布局辅助与相关前端回归测试。 +- 验收标准:主内容区右侧边缘出现 `A2` 贴边标签入口;标题栏与左侧 `Sider` 顶部都不再显示 AI 入口;按钮点击仍能正常打开/关闭 AI 面板,且打开后标签与面板外沿形成连续关系。 +- 依赖与约束:不修改 AI 面板展开方向;保持现有 store 结构与聊天逻辑;需兼容窄窗口下的点击区域。 + +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):确认 AI 入口应按“协作入口”重新设计 +- [x] 阶段 2(影响分析):定位标题栏、侧栏工具条与 AI 面板开关实现 +- [x] 阶段 3(方案设计):完成 `A2` 右侧贴边标签方案设计与评审 +- [x] 阶段 4(实施计划):输出实现计划文档 +- [x] 阶段 5(实现与自检):完成 helper 单测与前端构建验证 +- [ ] 阶段 6(评审与交付):待手工界面确认后整理风险、回滚和验证点 +- [ ] 阶段 7(发布与观察):待后续观察 + +## 4. 变更清单 +- 已完成:新增 `frontend/src/utils/aiEntryLayout.ts` 与配套单测;左侧 `Sider` 顶部恢复四个工具按钮;AI 入口改为主内容区右侧贴边标签;修复打开 AI 面板后贴边标签压住头部按钮区的问题,打开态现在附着到面板左外沿。 +- 进行中:无。 +- 待处理:本机手工确认贴边标签的实际视觉位置、打开态连续关系和窄窗口点击区。 + +## 5. 风险与阻塞 +- 风险:贴边标签如果尺寸过大,会重新变成悬浮按钮;如果过小,会影响关闭态可发现性;如果锚点错误,打开面板后会与外沿脱节。 +- 阻塞:自动化浏览器手工验证受限于本机 Playwright Chrome 安装失败(权限不足);尝试直接通过 Edge 拉起本地 URL 也被当前执行策略拦截,无法由 Codex 自动代开页面。 +- 缓解措施:当前已用 helper 单测与 `npm run build` 验证静态接线;界面最终位置仍需你在本机直接运行后再看一眼。 + +## 6. 决策记录 +- 决策 1:AI 入口定位为“协作入口”,而不是标题栏按钮或侧栏工具按钮。 +- 决策 2:放弃标题栏右侧与标题后入口方案,改为主内容区右侧贴边标签。 +- 决策 3:选定 `A2` 形态,即上角贴边标签,而不是竖向拉手或极简细窄开关。 +- 决策 4:左侧 `Sider` 顶部保留四个工具按钮,AI 不再回到该区域。 +- 决策 5:打开态贴边标签的锚点必须落在 `panel-shell` 左外沿,而不能落在面板右上角内部,否则会与 `AIChatHeader` 操作区重叠。 + +## 7. 验证记录 +- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts` +- 结果:通过,`6/6` 用例通过。 +- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vitest 输出 `1 passed / 6 passed`。 + +- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts`(打开态锚点回归) +- 结果:先失败后通过;新增打开态外侧附着回归后,最终 `8/8` 用例通过。 +- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中先得到 `resolveAIEdgeHandleDockStyle is not a function` 的红灯,随后补充 dock style helper 后重新执行,Vitest 输出 `1 passed / 8 passed`。 + +- 验证项:`npm run build` +- 结果:通过,`tsc && vite build` 退出码为 `0`。 +- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vite 完成生产构建,存在既有 chunk size warning,但不阻塞本次改动。 + +- 手工验证项:浏览器态界面核对 +- 结果:未完成。 +- 证据(日志/截图/链接):2026-03-28 16:24:29 +08:00 再次尝试时,Playwright 仍因缺少 Chrome 且安装需要更高权限而失败;尝试通过本机 Edge 打开 `http://localhost:5173` 时又被当前命令执行策略拦截,未能自动采集界面证据。 + +- 计划文档:`docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md` +- 设计文档:`docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md` + +## 8. 下一步 +- 下一步行动:当前实现先保留在隔离 worktree `C:\Users\yangguofeng\.config\superpowers\worktrees\GoNavi\ai-edge-handle-entry`,待你本机目测确认后,再决定是否合并回 `dev`。 +- 负责人:Codex diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 457b836..00cbaaa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,13 @@ import { isShortcutMatch, normalizeShortcutCombo, } from './utils/shortcuts'; +import { + SIDEBAR_UTILITY_ITEM_KEYS, + resolveAIEntryPlacement, + resolveAIEdgeHandleAttachment, + resolveAIEdgeHandleDockStyle, + resolveAIEdgeHandleStyle, +} from './utils/aiEntryLayout'; import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App'; import './App.css'; @@ -1125,6 +1132,61 @@ function App() { const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); const [isProxyModalOpen, setIsProxyModalOpen] = useState(false); const [isAISettingsOpen, setIsAISettingsOpen] = useState(false); + const aiEntryPlacement = resolveAIEntryPlacement(); + const aiEdgeHandleAttachment = resolveAIEdgeHandleAttachment(aiPanelVisible); + const aiEdgeHandleDockStyle = useMemo( + () => resolveAIEdgeHandleDockStyle(aiEdgeHandleAttachment), + [aiEdgeHandleAttachment], + ); + const aiEdgeHandleStyle = useMemo(() => ( + resolveAIEdgeHandleStyle({ + darkMode, + aiPanelVisible, + effectiveUiScale, + }) + ), [aiPanelVisible, darkMode, effectiveUiScale]); + const sidebarUtilityItems = useMemo(() => { + const itemMap = { + tools: { + key: 'tools', + title: '工具', + icon: , + onClick: () => setIsToolsModalOpen(true), + }, + proxy: { + key: 'proxy', + title: '代理', + icon: , + onClick: () => setIsProxyModalOpen(true), + }, + theme: { + key: 'theme', + title: '主题', + icon: , + onClick: () => setIsThemeModalOpen(true), + }, + about: { + key: 'about', + title: '关于', + icon: , + onClick: () => setIsAboutOpen(true), + }, + } as const; + + return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]); + }, []); + const renderAIEdgeHandle = () => ( + + + + ); // Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制 @@ -1634,24 +1696,12 @@ function App() { >
-
-
@@ -1760,12 +1810,24 @@ function App() { /> -
+
+ {aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'content-shell' && ( +
+ {renderAIEdgeHandle()} +
+ )} {aiPanelVisible && ( - setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} /> +
+ {aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'panel-shell' && ( +
+ {renderAIEdgeHandle()} +
+ )} + setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} /> +
)}
{isLogPanelOpen && ( diff --git a/frontend/src/utils/aiEntryLayout.test.ts b/frontend/src/utils/aiEntryLayout.test.ts new file mode 100644 index 0000000..e44f444 --- /dev/null +++ b/frontend/src/utils/aiEntryLayout.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + SIDEBAR_UTILITY_ITEM_KEYS, + resolveAIEntryPlacement, + resolveAIEdgeHandleAttachment, + resolveAIEdgeHandleDockStyle, + resolveAIEdgeHandleStyle, +} from './aiEntryLayout'; + +describe('ai entry layout', () => { + it('keeps the sidebar utility group free of the AI entry', () => { + expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']); + }); + + it('anchors the AI entry to the content edge', () => { + expect(resolveAIEntryPlacement()).toBe('content-edge'); + }); + + it('attaches the closed handle to the content shell', () => { + expect(resolveAIEdgeHandleAttachment(false)).toBe('content-shell'); + }); + + it('attaches the open handle to the panel shell', () => { + expect(resolveAIEdgeHandleAttachment(true)).toBe('panel-shell'); + }); + + it('keeps the closed handle docked on the content edge', () => { + expect(resolveAIEdgeHandleDockStyle('content-shell')).toMatchObject({ + position: 'absolute', + top: 16, + right: 0, + zIndex: 12, + }); + }); + + it('keeps the open handle outside the panel shell to avoid header overlap', () => { + expect(resolveAIEdgeHandleDockStyle('panel-shell')).toMatchObject({ + position: 'absolute', + top: 16, + right: '100%', + zIndex: 12, + }); + }); + + it('uses the attached active appearance when the AI panel is open', () => { + const style = resolveAIEdgeHandleStyle({ + darkMode: true, + aiPanelVisible: true, + effectiveUiScale: 1, + }); + + expect(style.color).toBe('#ffd666'); + expect(style.background).toBe('rgba(255,214,102,0.12)'); + expect(style.borderRadius).toBe('10px 0 0 10px'); + expect(style.borderRight).toBe('none'); + expect(style.height).toBe(24); + }); + + it('uses the subdued attached appearance when the AI panel is closed', () => { + const style = resolveAIEdgeHandleStyle({ + darkMode: false, + aiPanelVisible: false, + effectiveUiScale: 1, + }); + + expect(style.color).toBe('rgba(22,32,51,0.82)'); + expect(style.background).toBe('rgba(15,23,42,0.04)'); + expect(style.paddingInline).toBe(8); + expect(style.borderRadius).toBe('10px 0 0 10px'); + }); +}); diff --git a/frontend/src/utils/aiEntryLayout.ts b/frontend/src/utils/aiEntryLayout.ts new file mode 100644 index 0000000..a0624fb --- /dev/null +++ b/frontend/src/utils/aiEntryLayout.ts @@ -0,0 +1,59 @@ +import type { CSSProperties } from 'react'; + +export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const; + +export type AIEntryPlacement = 'content-edge'; +export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell'; + +export interface ResolveAIEdgeHandleStyleInput { + darkMode: boolean; + aiPanelVisible: boolean; + effectiveUiScale: number; +} + +export const resolveAIEntryPlacement = (): AIEntryPlacement => 'content-edge'; + +export const resolveAIEdgeHandleAttachment = ( + aiPanelVisible: boolean, +): AIEdgeHandleAttachment => (aiPanelVisible ? 'panel-shell' : 'content-shell'); + +export const resolveAIEdgeHandleDockStyle = ( + attachment: AIEdgeHandleAttachment, +): CSSProperties => ({ + position: 'absolute', + top: 16, + right: attachment === 'panel-shell' ? '100%' : 0, + zIndex: 12, +}); + +export const resolveAIEdgeHandleStyle = ({ + darkMode, + aiPanelVisible, + effectiveUiScale, +}: ResolveAIEdgeHandleStyleInput): CSSProperties => { + const inactiveColor = darkMode ? 'rgba(255,255,255,0.86)' : 'rgba(22,32,51,0.82)'; + const inactiveBackground = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)'; + const inactiveBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)'; + + return { + height: Math.max(24, Math.round(24 * effectiveUiScale)), + paddingInline: Math.max(8, Math.round(8 * effectiveUiScale)), + borderRadius: '10px 0 0 10px', + border: `1px solid ${aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.18)') : inactiveBorder}`, + borderRight: 'none', + background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)') : inactiveBackground, + color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : inactiveColor, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: Math.max(4, Math.round(4 * effectiveUiScale)), + fontSize: Math.max(12, Math.round(12 * effectiveUiScale)), + fontWeight: 600, + lineHeight: 1, + boxShadow: 'none', + backdropFilter: 'none', + WebkitBackdropFilter: 'none', + whiteSpace: 'nowrap', + flexShrink: 0, + }; +}; From fcd4d4026c04d9664e310c77bafe3f8f487b929b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Mar 2026 17:35:21 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=A7=20chore(gitignore):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=9C=AC=E5=9C=B0=E8=BF=BD=E8=B8=AA=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=B9=B6=E8=A1=A5=E5=85=85=E5=BF=BD=E7=95=A5=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从版本控制中移除 docs/superpowers 下的计划与设计文档 - 从版本控制中移除 docs/需求追踪 下的本地进度追踪文档 - 补充忽略规则,避免本地需求追踪与 superpowers 文档再次误提交 --- .gitignore | 2 + .../plans/2026-03-28-ai-edge-handle-entry.md | 87 ---------- .../2026-03-28-ai-edge-handle-entry-design.md | 158 ------------------ ...度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md | 63 ------- 4 files changed, 2 insertions(+), 308 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md delete mode 100644 docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md delete mode 100644 docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md diff --git a/.gitignore b/.gitignore index 6a07141..f70d8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ GoNavi-Wails.exe .claude/ .gemini/ **/tmpclaude-* +docs/superpowers/ +docs/需求追踪/ CLAUDE.md **/CLAUDE.md diff --git a/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md b/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md deleted file mode 100644 index dd4c510..0000000 --- a/docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md +++ /dev/null @@ -1,87 +0,0 @@ -# AI Edge Handle Entry Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 将 AI 助手入口从标题栏移除,改为主内容区右侧的 `A2` 贴边标签入口,并保持现有 AI 面板开关与聊天逻辑不变。 - -**Architecture:** 保留现有 `App.tsx` 作为 AI 入口装配点,不改 AI 面板展开方向和 store 状态。通过 `aiEntryLayout.ts` 承载可测试的布局/样式决策,让 `App.tsx` 只负责在“主内容关闭态”和“面板展开态”两个壳层里挂载同一个贴边标签。 - -**Tech Stack:** React 18, TypeScript, Ant Design, Vitest, Vite - ---- - -## File Map - -- Modify: `frontend/src/App.tsx` - - 移除标题栏 AI 入口渲染与相关样式状态 - - 在主内容区右边缘挂载关闭态入口 - - 在 AI 面板外沿挂载打开态入口 -- Modify: `frontend/src/utils/aiEntryLayout.ts` - - 将现有标题栏专用布局 helper 改为右侧贴边标签 helper - - 保留 `SIDEBAR_UTILITY_ITEM_KEYS` -- Modify: `frontend/src/utils/aiEntryLayout.test.ts` - - 先写失败测试,锁定入口位置、附着位置和贴边标签样式约束 -- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md` - - 同步“设计已切换到右侧贴边标签方案”和后续验证状态 - -## Chunk 1: Layout Contract Regression - -### Task 1: 把布局辅助契约改成“右侧贴边标签” - -**Files:** -- Modify: `frontend/src/utils/aiEntryLayout.test.ts` -- Modify: `frontend/src/utils/aiEntryLayout.ts` - -- [ ] **Step 1: 写失败测试,锁定新的布局语义** -- [ ] **Step 2: 运行测试,确认它因旧契约而失败** -- [ ] **Step 3: 用最小改动实现新的 helper 契约** -- [ ] **Step 4: 重新运行测试,确认 helper 契约变绿** -- [ ] **Step 5: 提交这一小块** - -## Chunk 2: Remove Titlebar Entry - -### Task 2: 从标题栏彻底移除 AI 入口 - -**Files:** -- Modify: `frontend/src/App.tsx` - -- [ ] **Step 1: 写出本次要删除的标题栏入口代码清单** -- [ ] **Step 2: 删除标题栏 AI 入口相关状态与 JSX** -- [ ] **Step 3: 运行前端构建,确认清理后没有遗留引用** -- [ ] **Step 4: 提交标题栏清理** - -## Chunk 3: Mount The Edge Handle - -### Task 3: 在主内容区右边缘挂载关闭态贴边标签 - -**Files:** -- Modify: `frontend/src/App.tsx` -- Modify: `frontend/src/utils/aiEntryLayout.ts` - -- [ ] **Step 1: 为关闭态入口写最小布局决策代码** -- [ ] **Step 2: 把主内容横向容器改成可承载绝对定位标签** -- [ ] **Step 3: 运行构建,确认关闭态入口接入不破坏布局** -- [ ] **Step 4: 提交关闭态入口挂载** - -### Task 4: 打开 AI 面板时让标签贴住面板外沿 - -**Files:** -- Modify: `frontend/src/App.tsx` - -- [ ] **Step 1: 给 AI 面板外层加一个仅负责贴边标签定位的壳层** -- [ ] **Step 2: 用附着位置决策保证开关态只渲染一个入口** -- [ ] **Step 3: 重新运行 helper 测试和构建** -- [ ] **Step 4: 提交打开态连续关系** - -## Chunk 4: Manual Verification And Docs - -### Task 5: 手工验证空间关系,再更新追踪文档 - -**Files:** -- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md` - -- [ ] **Step 1: 启动本地开发环境做手工验证** -- [ ] **Step 2: 如发现重叠,优先调整偏移而不是改回胶囊按钮** -- [ ] **Step 3: 运行最终验证命令** -- [ ] **Step 4: 更新需求追踪文档** -- [ ] **Step 5: 提交收尾文档与验证结果** diff --git a/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md b/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md deleted file mode 100644 index c49bf2b..0000000 --- a/docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md +++ /dev/null @@ -1,158 +0,0 @@ -# AI 助手入口重设计:右侧贴边标签方案 - -## 1. 背景 - -当前 AI 助手入口已经尝试过两种方案: - -1. 标题栏右侧、靠近窗口控制按钮的胶囊按钮 -2. 标题栏左侧、紧跟应用标题的胶囊按钮 - -这两种方案都存在同一个问题:入口仍然被感知为“标题栏按钮”或“全局工具按钮”,而不是“从右侧展开的协作面板入口”。用户已经明确选择将 AI 助手定义为协作入口,而非标题栏功能按钮或左侧工作流工具。 - -本次设计目标是重新定义 AI 助手入口的空间归属关系,让入口与右侧 AI 面板形成直接、自然的视觉和交互映射。 - -## 2. 设计目标 - -- 让 AI 入口看起来像右侧面板的入口,而不是标题栏按钮 -- 让入口位置与 AI 面板展开方向保持一致 -- 降低入口对标题栏和主工作区视觉层级的干扰 -- 保持现有 AI 面板开关逻辑、面板位置、聊天状态和快捷键不变 -- 保持左侧 `Sider` 顶部工具组为四个常规工具入口,不再放回 AI - -## 3. 非目标 - -- 不修改 AI 面板本体的信息架构 -- 不修改 AI 聊天、会话、模型、上下文逻辑 -- 不新增 AI 专属状态存储字段 -- 不重新设计标题栏 -- 不把 AI 面板改成左侧抽屉或底部抽屉 - -## 4. 已评估方案 - -### 方案 A:标题栏入口 - -把 AI 放在标题栏右侧或标题后方。 - -结论:放弃。 - -原因: - -- 容易被识别成窗口级按钮,而不是右侧协作面板入口 -- 入口与 AI 面板的空间关系过远 -- 标题栏承担拖拽、双击、窗口控制等系统职责,额外入口容易显得突兀 - -### 方案 B:内容区悬浮胶囊 - -把 AI 入口放在内容区右上角,作为脱离标题栏的悬浮按钮。 - -结论:未采用。 - -原因: - -- 比标题栏好,但仍然像“漂浮的按钮” -- 视觉上还是偏独立动作,而不是面板把手 -- 容易与未来内容区工具条或右上角操作区冲突 - -### 方案 C:右侧贴边入口 - -把 AI 入口挂在主内容区右边缘、贴近 AI 面板出现位置。 - -结论:采用。 - -原因: - -- 最符合“右侧面板从这里展开”的直觉 -- 可以脱离标题栏和窗口控制区 -- 与 AI 面板的视觉关系最强 -- 更符合“协作入口”而不是“工具按钮”的产品定位 - -## 5. 最终方案 - -### 5.1 位置 - -- AI 入口彻底离开标题栏 -- AI 入口不再出现在左侧 `Sider` 顶部工具组 -- 入口挂载在主内容区容器内部,而不是窗口根节点 -- 位置锚点是主内容区最右侧边缘,即现有 AI 面板展开出来的那条边 -- 默认位于内容区右上方,明显低于标题栏底边,避免再与标题栏形成视觉混淆 - -### 5.2 形态 - -选定形态为 `A2:上角贴边标签`。 - -入口外观定义如下: - -- 采用“半嵌入式贴边标签”而不是完整胶囊按钮 -- 标签右侧贴边,左侧圆角外露 -- 默认文案收敛为 `AI` -- 保留一个轻量机器人图标,用于快速识别 -- 视觉风格使用面板边框色、弱背景和轻圆角 -- 不使用强阴影、不使用主按钮视觉、不做明显浮层感 - -目标效果是:入口更像右侧协作面板的标签,而不是单独悬浮的交互控件。 - -### 5.3 状态 - -关闭态: - -- 标签保持低存在感 -- 弱背景、弱边框、常规文字色 -- 保证可发现,但不抢主内容注意力 - -悬停态: - -- 仅做轻微提亮 -- 允许边框轻微增强 -- 不放大、不上浮、不出现明显浮层阴影 - -打开态: - -- 标签切换到 AI 激活色 -- 视觉上与右侧 AI 面板顶部外沿形成连续感 -- 不隐藏入口,让用户明确知道“标签已经展开成右侧这块面板” - -## 6. 交互定义 - -- 点击贴边标签时,仍然调用现有 AI 面板开关逻辑 -- AI 面板继续从右侧展开,不改变方向 -- 关闭面板后,标签恢复为单独的贴边入口 -- 打开面板时,标签保留在面板外沿作为“已展开标识” -- 当面板宽度被拖动时,标签始终贴在面板外沿,不与主内容区脱节 -- 在窄窗口下优先保证标签可点击,必要时继续保留 `AI` 简写,不要求展开成长文案 - -## 7. 实现边界 - -- 主入口显示与定位由 `App.tsx` 管理 -- AI 面板本身只保留现有展开、关闭和宽度调整行为 -- 状态继续复用现有 `aiPanelVisible`、`toggleAIPanel`、`setAIPanelVisible` -- 不新增持久化字段,不改 store 结构 -- 左侧 `Sider` 顶部继续保留四个工具按钮:`工具 / 代理 / 主题 / 关于` -- 标题栏恢复为纯窗口级区域,不继续承载 AI 入口 - -## 8. 验证口径 - -实现后重点验证以下事项: - -1. 关闭态时,右侧贴边标签是否自然,不像悬浮按钮 -2. 打开态时,标签是否与 AI 面板形成清晰连续关系 -3. 拖动 AI 面板宽度后,标签是否仍贴在面板外沿 -4. 窄窗口下标签是否仍可点击,不与内容区遮挡 -5. 标题栏空白区、窗口控制按钮、左侧侧栏工具组是否恢复干净,不再承担 AI 入口职责 - -## 9. 风险与注意事项 - -- 贴边标签如果做得过大,会重新变成“漂浮按钮” -- 贴边标签如果做得过窄,会影响关闭态可发现性 -- 标签定位需要明确绑定主内容区右边界,而不是整个窗口最右边界,否则在不同布局状态下容易错位 -- 打开态与面板外沿的连续关系必须处理好,否则会显得像两个无关控件 - -## 10. 决策结论 - -本次 AI 助手入口重设计的最终结论是: - -- 不再使用标题栏入口 -- 不回退到左侧工具条入口 -- 采用主内容区右侧的贴边标签方案 -- 采用 `A2` 形态,即“上角贴边标签” - -这是当前最符合“协作入口”定位的方案,也是最能解释 AI 面板空间来源的方案。 diff --git a/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md b/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md deleted file mode 100644 index b5261ee..0000000 --- a/docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md +++ /dev/null @@ -1,63 +0,0 @@ -# 需求进度追踪 - AI助手入口迁移到主内容区右侧贴边标签 - -## 1. 需求摘要 -- 需求名称:AI 助手入口迁移到主内容区右侧贴边标签 -- 提出日期:2026-03-28 -- 负责人:Codex -- 目标:将 AI 助手入口从左侧 `Sider` 顶部工具条和标题栏中移出,改为主内容区右侧边缘的 `A2` 贴边标签入口,并保持现有 AI 面板开关逻辑不变。 -- 非目标:不调整 AI 面板展开方向,不修改 AI 聊天逻辑,不新增快捷键或状态存储。 - -## 2. 范围与验收 -- 范围:`App.tsx` 主内容区与 AI 面板装配位置、左侧 `Sider` 顶部工具条、`aiEntryLayout` 布局辅助与相关前端回归测试。 -- 验收标准:主内容区右侧边缘出现 `A2` 贴边标签入口;标题栏与左侧 `Sider` 顶部都不再显示 AI 入口;按钮点击仍能正常打开/关闭 AI 面板,且打开后标签与面板外沿形成连续关系。 -- 依赖与约束:不修改 AI 面板展开方向;保持现有 store 结构与聊天逻辑;需兼容窄窗口下的点击区域。 - -## 3. 里程碑与进度 -- [x] 阶段 1(需求澄清):确认 AI 入口应按“协作入口”重新设计 -- [x] 阶段 2(影响分析):定位标题栏、侧栏工具条与 AI 面板开关实现 -- [x] 阶段 3(方案设计):完成 `A2` 右侧贴边标签方案设计与评审 -- [x] 阶段 4(实施计划):输出实现计划文档 -- [x] 阶段 5(实现与自检):完成 helper 单测与前端构建验证 -- [ ] 阶段 6(评审与交付):待手工界面确认后整理风险、回滚和验证点 -- [ ] 阶段 7(发布与观察):待后续观察 - -## 4. 变更清单 -- 已完成:新增 `frontend/src/utils/aiEntryLayout.ts` 与配套单测;左侧 `Sider` 顶部恢复四个工具按钮;AI 入口改为主内容区右侧贴边标签;修复打开 AI 面板后贴边标签压住头部按钮区的问题,打开态现在附着到面板左外沿。 -- 进行中:无。 -- 待处理:本机手工确认贴边标签的实际视觉位置、打开态连续关系和窄窗口点击区。 - -## 5. 风险与阻塞 -- 风险:贴边标签如果尺寸过大,会重新变成悬浮按钮;如果过小,会影响关闭态可发现性;如果锚点错误,打开面板后会与外沿脱节。 -- 阻塞:自动化浏览器手工验证受限于本机 Playwright Chrome 安装失败(权限不足);尝试直接通过 Edge 拉起本地 URL 也被当前执行策略拦截,无法由 Codex 自动代开页面。 -- 缓解措施:当前已用 helper 单测与 `npm run build` 验证静态接线;界面最终位置仍需你在本机直接运行后再看一眼。 - -## 6. 决策记录 -- 决策 1:AI 入口定位为“协作入口”,而不是标题栏按钮或侧栏工具按钮。 -- 决策 2:放弃标题栏右侧与标题后入口方案,改为主内容区右侧贴边标签。 -- 决策 3:选定 `A2` 形态,即上角贴边标签,而不是竖向拉手或极简细窄开关。 -- 决策 4:左侧 `Sider` 顶部保留四个工具按钮,AI 不再回到该区域。 -- 决策 5:打开态贴边标签的锚点必须落在 `panel-shell` 左外沿,而不能落在面板右上角内部,否则会与 `AIChatHeader` 操作区重叠。 - -## 7. 验证记录 -- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts` -- 结果:通过,`6/6` 用例通过。 -- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vitest 输出 `1 passed / 6 passed`。 - -- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts`(打开态锚点回归) -- 结果:先失败后通过;新增打开态外侧附着回归后,最终 `8/8` 用例通过。 -- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中先得到 `resolveAIEdgeHandleDockStyle is not a function` 的红灯,随后补充 dock style helper 后重新执行,Vitest 输出 `1 passed / 8 passed`。 - -- 验证项:`npm run build` -- 结果:通过,`tsc && vite build` 退出码为 `0`。 -- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vite 完成生产构建,存在既有 chunk size warning,但不阻塞本次改动。 - -- 手工验证项:浏览器态界面核对 -- 结果:未完成。 -- 证据(日志/截图/链接):2026-03-28 16:24:29 +08:00 再次尝试时,Playwright 仍因缺少 Chrome 且安装需要更高权限而失败;尝试通过本机 Edge 打开 `http://localhost:5173` 时又被当前命令执行策略拦截,未能自动采集界面证据。 - -- 计划文档:`docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md` -- 设计文档:`docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md` - -## 8. 下一步 -- 下一步行动:当前实现先保留在隔离 worktree `C:\Users\yangguofeng\.config\superpowers\worktrees\GoNavi\ai-edge-handle-entry`,待你本机目测确认后,再决定是否合并回 `dev`。 -- 负责人:Codex From eeef0f06ede02a54475f50d676e8b3b05dc2a8f5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Mar 2026 17:40:27 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix(app):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BE=9B=E5=BA=94=E5=95=86=E9=A2=84=E8=AE=BE=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E5=B9=B6=E5=85=BC=E5=AE=B9Wails=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=B5=84=E6=BA=90=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽离供应商预设匹配逻辑,避免自定义 OpenAI 端点误识别为千问 Coding Plan - 调整 AI 设置弹窗的预设回填逻辑,并补充预设识别回归测试 - 通过 dev/prod build tag 拆分前端资源装配,避免开发模式依赖 frontend/dist --- assets_dev.go | 9 ++ assets_prod.go | 13 +++ frontend/package.json.md5 | 2 +- frontend/src/components/AISettingsModal.tsx | 29 +----- frontend/src/utils/aiProviderPresets.test.ts | 92 +++++++++++++++++-- frontend/src/utils/aiProviderPresets.ts | 93 +++++++++++++++++--- main.go | 4 - 7 files changed, 193 insertions(+), 49 deletions(-) create mode 100644 assets_dev.go create mode 100644 assets_prod.go diff --git a/assets_dev.go b/assets_dev.go new file mode 100644 index 0000000..2d3caf3 --- /dev/null +++ b/assets_dev.go @@ -0,0 +1,9 @@ +//go:build dev + +package main + +import "os" + +// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS, +// 避免编译时依赖 frontend/dist 被并发重建。 +var assets = os.DirFS(".") diff --git a/assets_prod.go b/assets_prod.go new file mode 100644 index 0000000..6cf94d5 --- /dev/null +++ b/assets_prod.go @@ -0,0 +1,13 @@ +//go:build !dev + +package main + +import ( + "embed" + "io/fs" +) + +//go:embed all:frontend/dist +var embeddedAssets embed.FS + +var assets fs.FS = embeddedAssets diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 3018db7..efbd2b6 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -dcb87159cf0f1f6f750d1c4870911d3f \ No newline at end of file +6ba85e4f456d2c0d230cab198c7dc02b \ No newline at end of file diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index e148379..403f352 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -3,12 +3,10 @@ import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Ta import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons'; import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types'; import { - getProviderFingerprint, - getProviderHostname, - matchQwenPresetKey, QWEN_BAILIAN_ANTHROPIC_BASE_URL, QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, QWEN_CODING_PLAN_MODELS, + resolveProviderPresetKey, resolvePresetBaseURL, resolvePresetModelSelection, resolvePresetTransport, @@ -62,28 +60,9 @@ const PROVIDER_PRESETS: ProviderPreset[] = [ const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; -const matchProviderPreset = (provider: Pick): ProviderPreset => { - const qwenPresetKey = matchQwenPresetKey(provider); - if (qwenPresetKey) { - return findPreset(qwenPresetKey); - } - const fingerprint = getProviderFingerprint(provider.baseUrl); - const exactPreset = PROVIDER_PRESETS.find(pr => - pr.backendType === provider.type - && fingerprint !== '' - && fingerprint === getProviderFingerprint(pr.defaultBaseUrl) - ); - if (exactPreset) { - return exactPreset; - } - - const host = getProviderHostname(provider.baseUrl); - if (host.endsWith('moonshot.cn')) { - return findPreset('moonshot'); - } - return PROVIDER_PRESETS.find(pr => pr.backendType === provider.type && host !== '' && host === getProviderHostname(pr.defaultBaseUrl)) - || PROVIDER_PRESETS.find(pr => pr.backendType === provider.type) - || findPreset('custom'); +const matchProviderPreset = (provider: Pick): ProviderPreset => { + const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom'); + return findPreset(presetKey); }; const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [ diff --git a/frontend/src/utils/aiProviderPresets.test.ts b/frontend/src/utils/aiProviderPresets.test.ts index 98bfc67..c1384e1 100644 --- a/frontend/src/utils/aiProviderPresets.test.ts +++ b/frontend/src/utils/aiProviderPresets.test.ts @@ -1,15 +1,37 @@ import { describe, expect, it } from 'vitest'; - +import type { AIProviderType } from '../types'; import { - matchQwenPresetKey, + LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + QWEN_BAILIAN_ANTHROPIC_BASE_URL, QWEN_BAILIAN_MODELS_BASE_URL, QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, QWEN_CODING_PLAN_MODELS, + matchQwenPresetKey, resolvePresetBaseURL, resolvePresetModelSelection, resolvePresetTransport, + resolveProviderPresetKey, } from './aiProviderPresets'; +type PresetMatcher = { + key: string; + backendType: AIProviderType; + defaultBaseUrl: string; + fixedApiFormat?: string; +}; + +const PRESETS: PresetMatcher[] = [ + { key: 'openai', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1' }, + { key: 'qwen-bailian', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL }, + { + key: 'qwen-coding-plan', + backendType: 'custom', + defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + fixedApiFormat: 'claude-cli', + }, + { key: 'custom', backendType: 'custom', defaultBaseUrl: '' }, +]; + describe('ai provider preset helpers', () => { it('maps legacy Bailian compatible-mode URL back to the Bailian preset', () => { expect(matchQwenPresetKey({ @@ -18,13 +40,6 @@ describe('ai provider preset helpers', () => { })).toBe('qwen-bailian'); }); - it('maps Coding Plan anthropic URL to the dedicated Coding Plan preset', () => { - expect(matchQwenPresetKey({ - type: 'anthropic', - baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, - })).toBe('qwen-coding-plan'); - }); - it('maps Coding Plan Claude CLI config back to the dedicated Coding Plan preset', () => { expect(matchQwenPresetKey({ type: 'custom', @@ -33,6 +48,21 @@ describe('ai provider preset helpers', () => { })).toBe('qwen-coding-plan'); }); + it('maps legacy Coding Plan OpenAI config back to the dedicated Coding Plan preset', () => { + expect(matchQwenPresetKey({ + type: 'openai', + baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + })).toBe('qwen-coding-plan'); + }); + + it('does not treat a custom OpenAI endpoint as the built-in Coding Plan preset', () => { + expect(matchQwenPresetKey({ + type: 'custom', + apiFormat: 'openai', + baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + })).toBeNull(); + }); + it('does not keep a baked-in model list for the Coding Plan preset', () => { expect(QWEN_CODING_PLAN_MODELS).toEqual([ 'qwen3.5-plus', @@ -109,3 +139,47 @@ describe('ai provider preset helpers', () => { }); }); }); + +describe('resolveProviderPresetKey', () => { + it('不会把自定义 OpenAI 端点误识别成千问 Coding Plan', () => { + const key = resolveProviderPresetKey( + { + type: 'custom', + apiFormat: 'openai', + baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('custom'); + }); + + it('仍然能识别当前内置的千问 Coding Plan 预设', () => { + const key = resolveProviderPresetKey( + { + type: 'custom', + apiFormat: 'claude-cli', + baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('qwen-coding-plan'); + }); + + it('仍然能识别当前内置的千问百炼预设', () => { + const key = resolveProviderPresetKey( + { + type: 'anthropic', + apiFormat: undefined, + baseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('qwen-bailian'); + }); +}); diff --git a/frontend/src/utils/aiProviderPresets.ts b/frontend/src/utils/aiProviderPresets.ts index 7d39a5c..0e96558 100644 --- a/frontend/src/utils/aiProviderPresets.ts +++ b/frontend/src/utils/aiProviderPresets.ts @@ -49,6 +49,13 @@ export interface ResolvePresetTransportResult { apiFormat?: string; } +export interface ProviderPresetMatcher { + key: string; + backendType: AIProviderType; + defaultBaseUrl: string; + fixedApiFormat?: string; +} + export const getProviderHostname = (raw?: string): string => { if (!raw) return ''; try { @@ -71,25 +78,91 @@ export const getProviderFingerprint = (raw?: string): string => { export const matchQwenPresetKey = (provider: Pick): string | null => { const fingerprint = getProviderFingerprint(provider.baseUrl); - const bailianFingerprints = new Set([ - getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL), - getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL), - ]); - if (fingerprint !== '' && bailianFingerprints.has(fingerprint)) { + + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL) + && provider.type === 'anthropic' + ) { return 'qwen-bailian'; } - const codingPlanFingerprints = new Set([ - getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL), - getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL), - ]); - if (fingerprint !== '' && codingPlanFingerprints.has(fingerprint)) { + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL) + && provider.type === 'openai' + ) { + return 'qwen-bailian'; + } + + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL) + && provider.type === 'custom' + && provider.apiFormat === 'claude-cli' + ) { + return 'qwen-coding-plan'; + } + + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL) + && provider.type === 'openai' + ) { return 'qwen-coding-plan'; } return null; }; +export const resolveProviderPresetKey = ( + provider: Pick, + presets: ProviderPresetMatcher[], + fallbackKey = 'custom', +): string => { + const qwenPresetKey = matchQwenPresetKey(provider); + if (qwenPresetKey) { + return qwenPresetKey; + } + + const fingerprint = getProviderFingerprint(provider.baseUrl); + const exactPreset = presets.find((preset) => + preset.backendType === provider.type + && fingerprint !== '' + && fingerprint === getProviderFingerprint(preset.defaultBaseUrl) + && (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat), + ); + if (exactPreset) { + return exactPreset.key; + } + + // custom 供应商必须保守处理,避免仅凭 host 错误吞掉用户显式保存的自定义配置。 + if (provider.type === 'custom') { + return fallbackKey; + } + + const host = getProviderHostname(provider.baseUrl); + if (provider.type === 'anthropic' && host.endsWith('moonshot.cn')) { + const moonshotPreset = presets.find((preset) => preset.key === 'moonshot'); + if (moonshotPreset) { + return moonshotPreset.key; + } + } + + const hostPreset = presets.find((preset) => + preset.backendType === provider.type + && host !== '' + && host === getProviderHostname(preset.defaultBaseUrl) + && (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat), + ); + if (hostPreset) { + return hostPreset.key; + } + + const typePreset = presets.find((preset) => preset.backendType === provider.type && !preset.fixedApiFormat); + return typePreset?.key || fallbackKey; +}; + export const resolvePresetModelSelection = ({ presetKey, presetDefaultModel, diff --git a/main.go b/main.go index 8c4d999..d312635 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "embed" aiservice "GoNavi-Wails/internal/ai/service" "GoNavi-Wails/internal/app" @@ -15,9 +14,6 @@ import ( "github.com/wailsapp/wails/v2/pkg/options/windows" ) -//go:embed all:frontend/dist -var assets embed.FS - func main() { // Create an instance of the app structure application := app.NewApp()