cc 7 tuntia sitten
vanhempi
commit
1710b8f20d

+ 188 - 0
.workbuddy-ai/memory/2026-10-09.md

@@ -0,0 +1,188 @@
+# 2026-10-09
+
+## 启动前后端
+
+- 前端(`ai-electron/frontend`):`./node_modules/.bin/vite dev --port 3100 --strictPort` → http://localhost:3100/
+- 后端(`ai-server`):`mvn -pl ai-server spring-boot:run -Dspring-boot.run.workingDirectory=E:/workspace/zsjz-ai`
+  → http://127.0.0.1:8980/js/a (`Started App in 5.6s`,`/sys/health` 返回 200)
+- `/tmp/mvnw.sh` 已重建(本机 mvn 包装脚本,见 skill §1)。
+
+## ★ 环境陷阱:宿主注入的 `SERVER__PORT` 会污染 Spring Boot 端口
+
+首次启动报 `PortInUseException: Port 51536 is already in use`,而 `application.yaml` 明明写的是 `8980`。
+
+根因:WorkBuddy 宿主 shell 环境里有 `SERVER__PORT=51536` / `SERVER__HOST=127.0.0.1`。
+Spring Boot 的松散绑定把它解析成 `server.port` / `server.address`,
+**操作系统环境变量优先级高于 application.yaml**,于是后端去绑 51536 —— 而那个端口正是宿主进程自己在监听。
+
+解法(二选一):
+- `env -u SERVER__PORT -u SERVER__HOST bash /tmp/mvnw.sh ...`(推荐,恢复配置文件原行为)
+- 或 `-Dspring-boot.run.arguments=--server.port=8980`(命令行参数优先级最高)
+
+⚠️ 同类隐患:任何从本 shell 启动的 Spring Boot / Node 服务,都要先确认没有 `SERVER__*` 之类的宿主变量覆盖配置。
+
+## 智能工作台「插件与技能」:技能新增/编辑改造(按技能目录规范)
+
+需求:技能按规范落地(`SKILL.md` 必需 + `scripts/` `references/` `assets/` `testcases/` 可选),
+新增与编辑都要能管附属文件;查看详情要按**文件树**并能**看文件内容**。
+
+### 后端(ai-server)
+- `SkillStore`:
+  - `save(...)` 新增 7 参重载(`files` / `deletePaths`),**overwrite 时不再整目录删重建**
+    —— 旧行为会让编辑一次就丢光附属文件(既有隐患,本次一并修正);
+  - 新增 `readFile` 读单个文件:UTF-8 严格解码判文本/二进制、2MB 预览上限、路径穿越防护;
+  - 新增 `pruneEmptyDirs`(删空文件后不留空目录);旧 5 参重载保留,测试零改动。
+- 新增 `SkillFileContentVO`;`SaveSkillDTO` 加 `files`/`deletePaths`;
+  `AgentCapabilityService(+Impl)` 的 createSkill/updateSkill 扩参并新增 `readSkillFile`;
+  Controller 新增 `GET /capability/skills/{name}/file?path=`。
+- `SkillStoreTest` 补 4 个用例(附属文件写入/读回、路径穿越与 SKILL.md 拒绝、编辑保留未提交文件、
+  二进制 base64),15 个全绿。
+
+### 前端(ai-electron/frontend)
+- `src/ai/components/SkillEditorModal.vue` 重写为**新增/编辑共用**:按 4 个规范目录分页签管理文件
+  (增/改/删/改名),二进制文件只读且保存时原样保留;编辑模式读磁盘真实内容预填。
+- `src/ai/views/aiPlugin/index.vue`:技能详情抽屉改「左文件树 + 右内容预览」,点任意节点按需拉内容
+  (图片走 base64 预览);技能卡片加「编辑」入口。
+- `capabilityApi.readSkillFile` + `types.ts` 的 `SaveSkillFile` / `SkillFileContent`。
+
+### 验证
+- 后端:`mvn -pl ai-server test -Dtest=SkillStoreTest` 15/15 通过;已重启服务(8980)。
+- 前端:`vue-tsc` 对 `src/ai/**` 零错误(core 的 3 个是既有问题);eslint 干净;
+  stylelint 对比 HEAD 无新增错误(aiPlugin 既有 28 处 rgba/alpha 写法问题未动)。
+- **未验证**:UI 端到端 —— 接口需登录态,curl 只能确认路由已注册(返回业务码 401)。
+
+### 补充:技能编辑器补 label(用户反馈「不知道要填什么」)
+- `SkillEditorModal.vue`:技能名 / 描述 / 正文 / 附属文件名 / 附属文件内容 全部改成
+  「label 在上、控件在下」(`__field` + `__label` + `__req` 必填星号),placeholder 只留示例。
+- `AgentFormModal.vue` 专家弹窗里的技能子面板同样补 label:用 `__sfield` / `__slabel`,
+  **不能复用该文件的 `__label`** —— 那是横向表单行用的(`width:96px; text-align:right`),
+  而 `__seditor` 是纵向 flex 布局。
+- 验证:`vue-tsc` 对 `src/ai` 零错误;eslint 干净;stylelint 对比 HEAD 无新增(AgentFormModal 既有 6 处 rgba 写法未动)。
+
+### 补充:技能详情抽屉宽度回退
+- 用户反馈「查看详情侧边栏太宽」→ 抽屉从 1000 回到原来的 **720**,左文件树从 340 收到 **240**(两栏内容区约 424px)。
+- 结论:**详情抽屉宽度沿用 720 这个既有口径**,别再自行放大;两栏布局时左树固定 240px。
+
+### 补充:技能编辑弹框限高
+- 用户反馈「新增/编辑弹框不能一直增高,要用滚动条」→ `SkillEditorModal` 的 BasicModal 加
+  `:body-style="{ maxHeight: '64vh', overflowY: 'auto' }"`。
+- **口径**:本项目的长内容弹框统一用 `maxHeight: 64vh + overflowY: auto`
+  (aiPlugin 的工具包弹框、AgentFormModal、ArtifactPreviewModal 都是这套),新弹框照抄即可。
+
+### 技能详情改为只读弹框(与编辑弹框同构)
+- 用户要求「查看详情也用新增编辑一样的弹框,只读即可」→ 新增 `src/ai/components/SkillDetailModal.vue`:
+  900 宽 BasicModal、`:footer="null"`、bodyStyle 64vh 限高、SKILL.md + 四个规范目录页签全只读;
+  打开时读详情并**并发**拉全部附属文件内容(文本 content / 图片 base64)。
+- 抽 `src/ai/utils/skill.ts`:`SKILL_STD_DIRS` / `SKILL_NAME_RE` / `formatFileSize` / `splitSkillMd` /
+  `topDirOf` / `leafPath` / `groupSkillFiles` / `isImagePath` / `imageMimeOf` ——
+  详情与编辑弹框共用,避免两边各写一份后漂移。
+- `aiPlugin/index.vue` 大幅瘦身:删掉详情抽屉(a-drawer)、`skillTree`、`openDirs/isDirOpen/toggleDir/
+  dirTag/leafName/formatSize`、`openSkillFile`、`IMAGE_MIME/fileExt/isImageFile/imageMime`、
+  `readSkill/readSkillFile` 引用、`Drawer` import、`skillLoading`;改为
+  `<SkillDetailModal v-model:open="skillOpen" :skill="skillViewing" :case-id="caseId" />`。
+- ★ 顺手修掉上一轮引入的样式冲突:详情抽屉的 `&__detail` 与**工具详情弹框**既有的 `&__detail` 同名,
+  LESS 会把两条规则合并(工具的详情布局被带偏)—— 已随抽屉样式一起删除。
+- 验证:`vue-tsc` 对 `src/ai` 零错误;eslint 干净;stylelint 26 处(比改动前少 1,全是既有 rgba 写法)。
+
+### 研判方案「新建/编辑方案」弹框行距修正
+- 用户反馈「表单间距太大」→ 根因:`a-form-item` 默认带 `margin-bottom: 24px`(antd 给校验提示留位),
+  而这个弹框的字段名是**自己画的 label(在 form-item 外面)**,再叠 24px 会把行距从设计意图的
+  20px(gap 12 + row margin 8)顶成 44px。
+- 修法:`&__form :deep(.ant-form-item) { margin-bottom: 0; }`,行距交回 `__row` 统一控制。
+  校验失败时 explain 在 form-item 内部、会把该行撑高,不会压到下一行。
+- **同类坑**:凡是「自绘 label + a-form-item 只包控件」的表单,都要压平 form-item 的 24px 下边距。
+- 验证:eslint 干净;stylelint 6 处(与改动前一致,全是既有 rgba 写法)。
+
+### 研判方案弹框:移除「改技能」入口
+- 用户要求「弹框里选择技能那里不可以去编辑技能」→ `AgentFormModal.vue` 删除:
+  skillEditor 子面板(模板 + reactive 状态 + `openSkillEditor/closeSkillEditor/prefillSkill/saveSkill/
+  splitSkillMd/reloadSkills`)、技能卡片上的铅笔按钮(`__card-ops` / `__iconbtn` 及样式);
+  顺带清掉不再使用的 `Button` import 与 `SaveSkillParams` 类型引用。
+- 文案改为「技能的新增与编辑在『插件与技能 → 技能』页进行」,卡片只剩勾选语义。
+- **口径**:研判方案弹框里的技能/工具包一律只做勾选,**任何技能编辑能力都归「插件与技能」页**。
+- 验证:`vue-tsc` 对 `src/ai` 零错误;eslint 干净;stylelint 6 处(与改动前一致)。
+
+### 技能详情弹框:只读控件底纹置灰
+- 用户要求「查看技能的时候输入框这些底纹置灰色」→ `SkillDetailModal.vue` 加:
+  `:deep(.ant-input) { &[readonly] { background: var(--ai-bg-subtle, #f5f5f5); ... } }`,
+  hover/focus 一并覆盖为同色 + `box-shadow: none`,避免只读控件看起来像能编辑。
+- **口径**:antd 的 `readonly` 输入框默认仍是白底(只有 `disabled` 才自动变灰),
+  凡是「只读展示」的 a-input / a-textarea 都要自己压底色;底色取 `--ai-bg-subtle`
+  (= `@cb-vscode-input-background`,就是项目里输入框的底色 token)。
+- 验证:eslint 干净;stylelint 干净。**视觉未在真实页面确认**(需登录态)。
+
+### 工具界面中文化(展示名与调用名分离)
+
+需求:「工具界面工具名都是英文,能不能做成中文;会不会影响 agent 调用;有影响就加表字段」。
+
+- **结论:不影响调用,因此未加表字段。**
+  - 调用名是 `@Tool(name)` → `ToolSchema.getName()` → 模型 function name,与页面显示完全无关。
+  - 反过来,**直接改 `@Tool(name)` 为中文会炸**:OpenAI/兼容 API 的 function name 只接受
+    `[a-zA-Z0-9_-]{1,64}`;且 `agent_tool_pack.tool_names`、MCP `ToolDefinition.name`、
+    历史消息里的工具名都按英文名引用。
+- **依据(已实测)**:`ai-electron/frontend/src/ai/utils/constants.ts` 的 `TOOL_LABELS`
+  (上个提交 df03094 引入,聊天时间线 / 状态栏 / 产物栏在用)**已完整覆盖**后端全部 64 个 `@Tool`——
+  用 grep 提取 `@Tool(name=...)` 与 `TOOL_LABELS` 的 key 做 `comm` 差集,结果为空;
+  前端多出的 5 条是 MCP 案件工具(list_cases/open_case/current_case/close_case)与框架内置
+  (reset_equipped_tools),它们不进 `agent_tool` 表。所以工具页复用这张表即可 100% 中文,零后端/DB 改动。
+- 改动(全前端):
+  - `aiPlugin/index.vue`:工具卡片主标题、工具包编辑器卡片、工具包详情弹框的工具名 → `toolLabel()`;
+    英文调用名降为小字等宽副信息(新增 `__tool-code` / `__pack-card-code` / `__pack-tool-code`,
+    并给 `__card-meta` 加 flex 布局);工具详情弹框标题改中文并新增「调用名(模型按它调用工具)」块;
+    工具页与工具包编辑器两处搜索框都支持**中文名和英文名同时命中**;placeholder 补中文示例。
+  - `AgentFormModal.vue`:研判方案弹框里工具包卡片的工具 chips 同样走 `toolLabel()`。
+- 验证:eslint 干净;stylelint 与 HEAD 基线**完全一致**(aiPlugin 仅剩既有 8 处 rgba + 1 处
+  comment-empty-line-before + 1 处 `__mono` 的 font-family 引号;AgentFormModal 仅既有 2 处 rgba);
+  `vue-tsc` 全量 87 个错误全在 call/case/core/graph/otg/person/trans,**`src/ai` 零错误**。
+- **遗留隐患**:中文名表在前端,后端新增工具必须同步补 `TOOL_LABELS`(constants.ts 里已写明)。
+  若日后要「后端权威」,需在 `agent_tool` 加 `display_name` 列(固定表名 DDL 只进 `sql/agent_tool.sql`,
+  由人工执行 ALTER),但**省不掉**前端那份表 —— 聊天时间线是流式渲染,SSE 事件里只有 name。
+
+### 补充:工具界面只留中文(撤掉英文原名)
+
+- 用户要求「工具列表上不展示英文名称,只展示中文」→ 撤掉上一轮加的英文副信息:
+  `__tool-code` / `__pack-card-code` / `__pack-tool-code` 三处模板节点与样式、工具详情弹框的
+  「调用名」块、工具卡片的 `:title="toolName"`、工具包 chips 的 `:title`,以及 placeholder / hint
+  里的英文示例(`stat_trans_big` → 「大额交易」,`execute_sql` → 「执行 SQL 查询」)全部删除。
+- **口径**:工具界面上不出现任何英文工具名(含悬停提示与占位文案)。
+  但搜索仍**同时匹配中英文**——能力保留,只是不再提示;从日志里复制英文名来搜是常见用法。
+- 验证:eslint 干净;stylelint 与基线一致(8 rgba / 1 comment / 1 quotes,全既有);
+  `vue-tsc` 的 `src/ai` 零错误。
+
+### 自主 ⇄ 智能 模式切换:各模式记住自己的停留位置
+
+- 问题:来回切每次都掉回默认落点(切回自主 → 首页;切回智能 → 工作台默认视图)。
+- 根因两处:
+  1. `core/logics/workbenchMode.ts` 的 `setMode` 固定跳 `WORKBENCH_PATH` / `BASE_HOME`;
+  2. 智能工作台是**单路由全屏壳**(`/workbench`,内部 `view` 不落 URL),切走即卸载,视图回到默认落点。
+- 改法:
+  - `core/enums/cacheEnum.ts` 新增 `APP_WORKBENCH_PATH_KEY` / `APP_WORKBENCH_VIEW_KEY`。
+  - `core/logics/workbenchMode.ts` 新增 `get/setLastTraditionalPath`、`get/setLastWorkbenchView`(**sessionStorage**,
+    读写带 try/catch 兜底);`setMode` 改为「切走前记下当前页 → 切回时跳上次停留处,没记忆才回默认落点」。
+    切到 traditional 仍只在「当前在工作台」时才跳 —— 在传统壳点自己那一侧只对齐偏好,不该把人挪走。
+  - `ai/views/aiWorkbench/index.vue`:`view` 初值 = `?view=` 参数 ?? 上次停留视图 ?? `'chat'`;
+    `watch(view)` 持续写回;`userChoseView` 初值 = 是否有记忆视图;`applyDefaultView` 条件改为
+    `userChoseView && (view !== 'chat' || hasConversation)` —— 「对话」要靠会话上下文才成立,
+    刷新后 store 是新的、会话还没选,那种情况仍交给默认落点选一条,免得落在一屏空白欢迎页。
+- **为什么用 sessionStorage 而非 localStorage**:只在本次运行内有效,重启后各模式回默认落点,
+  免得上次遗留的页面在案件上下文变化后变成一次莫名其妙的跳转。
+- **会话/消息不用另外存**:`useChatStreamStore` 是 Pinia 全局单例(`defineStore('ai-chat-stream')`),
+  组件卸载不销毁;且 `store.init` 有幂等保护(`initialized && workspaceId 相同` 直接 return),
+  所以切回工作台时会话选择与消息都还在。
+- 验证:eslint / stylelint 干净;`vue-tsc` 的 `src/ai` 零错误,`src/core` 仅 3 个**既有**错误
+  (BasicButton 的 `Fn`、TableAction 的 `ActionItem`、useECharts 的恒真判断),与本次改动无关。
+- **未验证**:真实来回点击(需登录态 + 案件上下文)。
+
+### 提交 `1f7ae24`(分支 dev_4)
+
+- 范围:本轮工作 6 个文件(SkillDetailModal / aiPlugin / AgentFormModal / cacheEnum /
+  workbenchMode / aiWorkbench)+ 在途的工作台改动。
+- `aiWorkbench/index.vue` 里我的改动与**在途改动**(移除「文件管理」视图、去掉顶栏会话 chip)
+  交织在同一批 hunk 里,无法按路径拆开,因此连配套的 `ai/views/aiCaseData/` 两个已 staged 删除
+  一起提交(否则会留下两个没人引用的孤儿文件)。commit message 末尾已注明这一点。
+- 未提交:`ChatComposer.vue`、`aiExpert/index.vue`(纯 UI 元素顺序调整,与本轮无关)、
+  `.workbuddy-ai/memory/2026-10-09.md`(本地工作记忆,不入库)。
+- ⚠️ 提交命令末尾会出现 `PROGRAM BLOCKED BY SECURITY POLICY`(git 内部调用 `sc.exe` / `reg.exe`
+  被沙箱黑名单拦下)—— **提交本身已成功**,看 `git log` 的输出为准,别被这行吓到。
+
+

+ 29 - 28
ai-electron/frontend/src/ai/components/ChatComposer.vue

@@ -33,32 +33,6 @@
       </div>
 
       <div class="ai-composer__bar">
-        <!-- 研判方案 chip(只读):草稿态显示将绑定的研判方案,会话已建则显示绑定者 -->
-        <span v-if="expertName" class="ai-composer__expert" :title="expertTitle">
-          <Icon icon="mdi:brain" :size="14" />
-          <span class="ai-composer__expert-name">{{ expertName }}</span>
-        </span>
-
-        <!-- 模型入口收成一颗紧凑 chip:模型不常改,不值得占 200px 的宽度 -->
-        <a-dropdown :trigger="['click']" placement="topLeft" :disabled="disabled || streaming">
-          <button class="ai-composer__model" type="button" :disabled="disabled || streaming">
-            <Icon icon="mdi:chip" :size="14" />
-            <span class="ai-composer__model-name">{{ currentModelName }}</span>
-            <span class="ai-composer__model-caret">›</span>
-          </button>
-          <template #overlay>
-            <a-menu :selected-keys="selectedKeys" @click="onPickModel">
-              <a-menu-item v-for="opt in modelOptions" :key="String(opt.value)">{{ opt.label }}</a-menu-item>
-            </a-menu>
-          </template>
-        </a-dropdown>
-
-        <!-- 分析模板库:选模板填入输入框(只填不发) -->
-        <button class="ai-composer__tpl" type="button" title="分析模板库" @click="emit('templates')">
-          <Icon icon="mdi:bookmark-multiple-outline" :size="13" />
-          模板
-        </button>
-
         <!-- 附件:选择即上传;支持 txt/md/json/pdf/doc/docx/csv/xls/xlsx,图片音视频不支持 -->
         <button
           class="ai-composer__tpl"
@@ -78,8 +52,34 @@
           @change="onFilePicked"
         />
 
+        <!-- 分析模板库:选模板填入输入框(只填不发) -->
+        <button class="ai-composer__tpl" type="button" title="分析模板库" @click="emit('templates')">
+          <Icon icon="mdi:bookmark-multiple-outline" :size="13" />
+          模板
+        </button>
+
+        <!-- 研判方案 chip(只读):草稿态显示将绑定的研判方案,会话已建则显示绑定者 -->
+        <span v-if="expertName" class="ai-composer__expert" :title="expertTitle">
+          <Icon icon="mdi:brain" :size="14" />
+          <span class="ai-composer__expert-name">{{ expertName }}</span>
+        </span>
+
         <span class="ai-composer__spacer"></span>
 
+        <!-- 模型入口收成一颗紧凑 chip:模型不常改,不值得占 200px 的宽度 -->
+        <a-dropdown :trigger="['click']" placement="topLeft" :disabled="disabled || streaming">
+          <button class="ai-composer__model" type="button" :disabled="disabled || streaming">
+            <Icon icon="mdi:chip" :size="14" />
+            <span class="ai-composer__model-name">{{ currentModelName }}</span>
+            <span class="ai-composer__model-caret">›</span>
+          </button>
+          <template #overlay>
+            <a-menu :selected-keys="selectedKeys" @click="onPickModel">
+              <a-menu-item v-for="opt in modelOptions" :key="String(opt.value)">{{ opt.label }}</a-menu-item>
+            </a-menu>
+          </template>
+        </a-dropdown>
+
         <!-- 发送 / 停止:同一个位置、同一个尺寸,流式切换时不产生跳动 -->
         <button
           v-if="streaming"
@@ -114,8 +114,9 @@
    * 会话创建后模型绑定在服务端(session.modelId),这里切换模型会随本轮请求下发 modelId,
    * 后端回写会话绑定,因此"换模型"只需切换选择器、无需新建会话。
    *
-   * 布局:整体是一个圆角容器(输入框 + 底部操作行),操作行左侧是模型 chip、右侧是圆形发送按钮,
-   * 键位提示移到容器外下方 —— 它属于"使用说明",不该挤占输入区的横向空间。
+   * 布局:整体是一个圆角容器(输入框 + 底部操作行),操作行左侧是附件、模板、研判方案,
+   * 弹性空白把模型 chip 与圆形发送按钮推到右侧贴邻,键位提示移到容器外下方 ——
+   * 它属于"使用说明",不该挤占输入区的横向空间。
    */
   import { computed, nextTick, ref, watch } from 'vue';
   import { Dropdown, Menu, MenuItem, Textarea } from 'ant-design-vue';

+ 14 - 14
ai-electron/frontend/src/ai/views/aiExpert/index.vue

@@ -2,20 +2,6 @@
   <div class="ai-page ai-expert">
     <!-- 数据来源入口(设计稿 hero 区):「上传文件」按历史逻辑跳转上传数据页;「开始分析」=新建对话并绑定默认内置研判方案 -->
     <section class="ai-expert__hero-grid">
-      <article class="ai-expert__hero">
-        <div class="ai-expert__hero-icon"><Icon icon="mdi:upload" :size="22" /></div>
-        <div class="ai-expert__hero-body">
-          <div class="ai-expert__hero-title">数据清洗与治理</div>
-          <p class="ai-expert__hero-desc"
-            >上传案件文件后自动执行清洗与治理,形成规范、可分析的数据,供后续智能研判使用。</p
-          >
-        </div>
-        <button class="ai-expert__hero-start" type="button" @click="onUploadFile">
-          上传文件
-          <Icon icon="mdi:arrow-right" :size="15" />
-        </button>
-      </article>
-
       <article class="ai-expert__hero">
         <div class="ai-expert__hero-icon"><Icon icon="mdi:filter-variant" :size="22" /></div>
         <div class="ai-expert__hero-body">
@@ -29,6 +15,20 @@
           <Icon icon="mdi:arrow-right" :size="15" />
         </button>
       </article>
+
+      <article class="ai-expert__hero">
+        <div class="ai-expert__hero-icon"><Icon icon="mdi:upload" :size="22" /></div>
+        <div class="ai-expert__hero-body">
+          <div class="ai-expert__hero-title">数据清洗与治理</div>
+          <p class="ai-expert__hero-desc"
+            >上传案件文件后自动执行清洗与治理,形成规范、可分析的数据,供后续智能研判使用。</p
+          >
+        </div>
+        <button class="ai-expert__hero-start" type="button" @click="onUploadFile">
+          上传文件
+          <Icon icon="mdi:arrow-right" :size="15" />
+        </button>
+      </article>
     </section>
 
     <!-- 面板:顶栏(标题 + 摘要胶囊 + 搜索 + 操作)+ 提示 + 研判方案卡片网格 -->