Browse Source

feat: 支持图片/附件 SFTP 上传、多模态对话与 FAQ Markdown 编辑器

后端:

- 新增 SFTP 附件上传服务与 /attachment/upload 接口

- 新增 StorageProperties 集中化存储配置(支持 DataSize 50MB)

- ChatContext 新增 imageUrls,AI 调用链路透传多模态图片

- AssistantApp 构造 UserMessage(text, Media) 实现视觉输入

- ContentSafetyAdvisor MASK 脱敏时保留原消息图片 Media

- 配置拆分到 application-dev.yml / application-prod.yml(对齐参考项目)

前端:

- 对话面板支持图片/附件上传与缩略图/下载链接展示

- chat.ts 透传 imageUrls,chatAdapter 增加 Attachment 模型

- FAQ 编辑器替换为 md-editor-v3,支持粘贴图片自动上传

- FAQ 表格答案摘要去除 markdown 语法

- UI-DEV-GUIDE 补充 FormDrawer 使用规范
TDesign-AI-Chat
wanghanlin 2 days ago
parent
commit
d6a20f228a
  1. 4
      frontend/UI-DEV-GUIDE.md
  2. 1
      frontend/components.d.ts
  3. 613
      frontend/package-lock.json
  4. 1
      frontend/package.json
  5. 2
      frontend/src/api/chat.ts
  6. 5
      frontend/src/api/upload.ts
  7. 51
      frontend/src/components/FormDrawer.vue
  8. 10
      frontend/src/utils/chatAdapter.ts
  9. 101
      frontend/src/views/ChatPanel.vue
  10. 76
      frontend/src/views/FaqManager.vue
  11. 6
      pom.xml
  12. 18
      src/main/java/com/wok/supportbot/advisor/ContentSafetyAdvisor.java
  13. 63
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  14. 26
      src/main/java/com/wok/supportbot/app/ChatContext.java
  15. 69
      src/main/java/com/wok/supportbot/config/StorageProperties.java
  16. 38
      src/main/java/com/wok/supportbot/controller/AiController.java
  17. 99
      src/main/java/com/wok/supportbot/controller/AttachmentController.java
  18. 2
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  19. 143
      src/main/java/com/wok/supportbot/service/SftpStorageService.java
  20. 12
      src/main/resources/application-dev.yml
  21. 12
      src/main/resources/application-prod.yml
  22. 12
      src/main/resources/application.yml
  23. 38
      src/main/resources/static/sdk/test.html
  24. 98
      知识库文档管理模块验证报告.md

4
frontend/UI-DEV-GUIDE.md

@ -236,7 +236,7 @@ const columns = [
| 操作列 | `<t-space :size="4">` + `<t-button size="small" variant="text">`;危险操作 `theme="danger"`、主操作 `theme="primary"` | | 操作列 | `<t-space :size="4">` + `<t-button size="small" variant="text">`;危险操作 `theme="danger"`、主操作 `theme="primary"` |
| 状态列 | 用 `<t-tag variant="light" theme="...">` 表达,不用带色 `<span>` | | 状态列 | 用 `<t-tag variant="light" theme="...">` 表达,不用带色 `<span>` |
| 开关列 | `<t-switch v-model="row.xxx" @change="...">` | | 开关列 | `<t-switch v-model="row.xxx" @change="...">` |
| 弹窗 | 统一 `FormDialog` 组件 + `.dialog-footer`,`<t-form label-align="top">` |
| 弹窗 | 统一 `FormDialog` 组件 + `.dialog-footer`,`<t-form label-align="top">`;富文本/宽内容表单改用 `FormDrawer`(`t-drawer`) |
| 删除确认 | 统一 `useConfirm().confirm()`(已 100% 采用) | | 删除确认 | 统一 `useConfirm().confirm()`(已 100% 采用) |
| 提示 | 统一 `@/utils/toast`,**禁止**直接 `import { MessagePlugin }` | | 提示 | 统一 `@/utils/toast`,**禁止**直接 `import { MessagePlugin }` |
@ -308,6 +308,7 @@ import { AddIcon, SearchIcon, DeleteIcon, RefreshIcon, SettingIcon } from 'tdesi
| 组件 | 路径 | 用途 | | 组件 | 路径 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `FormDialog` | `@/components/FormDialog.vue` | 新增/编辑弹窗壳(封装 `t-dialog` + 页脚取消/保存按钮),表单内容走默认 slot,`extra-buttons` slot 放额外按钮 | | `FormDialog` | `@/components/FormDialog.vue` | 新增/编辑弹窗壳(封装 `t-dialog` + 页脚取消/保存按钮),表单内容走默认 slot,`extra-buttons` slot 放额外按钮 |
| `FormDrawer` | `@/components/FormDrawer.vue` | 新增/编辑抽屉表单壳(封装 `t-drawer` + 页脚取消/保存按钮),用于富文本/宽内容表单,接口同 `FormDialog` |
| `MessageSources` | `@/components/MessageSources.vue` | AI 引用来源展示 | | `MessageSources` | `@/components/MessageSources.vue` | AI 引用来源展示 |
### 工具(`src/utils/`) ### 工具(`src/utils/`)
@ -334,6 +335,7 @@ import { AddIcon, SearchIcon, DeleteIcon, RefreshIcon, SettingIcon } from 'tdesi
- ✅ 提交前调用 `formRef.value?.validate()` 触发校验(见 §④ 模板)。 - ✅ 提交前调用 `formRef.value?.validate()` 触发校验(见 §④ 模板)。
- 多字段布局统一 `<t-row :gutter="16"><t-col :span="6">`(`span=6` 为四列)。 - 多字段布局统一 `<t-row :gutter="16"><t-col :span="6">`(`span=6` 为四列)。
- 弹窗统一 `<t-dialog :footer="false">` + 页脚 `.dialog-footer` 放「取消 / 保存」,保存按钮 `:loading="saving"` 防重复提交。 - 弹窗统一 `<t-dialog :footer="false">` + 页脚 `.dialog-footer` 放「取消 / 保存」,保存按钮 `:loading="saving"` 防重复提交。
- **弹窗 vs 抽屉的取舍**:简单表单用 `t-dialog`(`FormDialog` 组件);**富文本/宽内容表单**(Markdown 编辑器、宽预览、长文本、宽表格编辑)统一用 `t-drawer`(`FormDrawer` 组件,`size` 可设 `'large'`/`'1000px'`/`'85%'`),避免窄弹窗挤压双栏编辑器。
- 必填/格式校验规则统一以 TDesign Form 校验规则为准(`required`、`trigger: 'blur'` 等)。 - 必填/格式校验规则统一以 TDesign Form 校验规则为准(`required`、`trigger: 'blur'` 等)。
--- ---

1
frontend/components.d.ts

@ -8,6 +8,7 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
FormDialog: typeof import('./src/components/FormDialog.vue')['default'] FormDialog: typeof import('./src/components/FormDialog.vue')['default']
FormDrawer: typeof import('./src/components/FormDrawer.vue')['default']
MessageSources: typeof import('./src/components/MessageSources.vue')['default'] MessageSources: typeof import('./src/components/MessageSources.vue')['default']
PromptSearchHighlight: typeof import('./src/components/PromptSearchHighlight.vue')['default'] PromptSearchHighlight: typeof import('./src/components/PromptSearchHighlight.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

613
frontend/package-lock.json

@ -13,6 +13,7 @@
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"marked": "^12.0.2", "marked": "^12.0.2",
"md-editor-v3": "^5.0.0",
"mermaid": "^11.4.1", "mermaid": "^11.4.1",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"tdesign-icons-vue-next": "^0.4.9", "tdesign-icons-vue-next": "^0.4.9",
@ -144,6 +145,30 @@
"@lezer/common": "^1.1.0" "@lezer/common": "^1.1.0"
} }
}, },
"node_modules/@codemirror/lang-angular": {
"version": "0.1.4",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz",
"integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.3"
}
},
"node_modules/@codemirror/lang-cpp": {
"version": "6.0.3",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
"integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/cpp": "^1.0.0"
}
},
"node_modules/@codemirror/lang-css": { "node_modules/@codemirror/lang-css": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
@ -157,6 +182,19 @@
"@lezer/css": "^1.1.7" "@lezer/css": "^1.1.7"
} }
}, },
"node_modules/@codemirror/lang-go": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/go": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": { "node_modules/@codemirror/lang-html": {
"version": "6.4.12", "version": "6.4.12",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-html/-/lang-html-6.4.12.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
@ -174,6 +212,16 @@
"@lezer/html": "^1.3.12" "@lezer/html": "^1.3.12"
} }
}, },
"node_modules/@codemirror/lang-java": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
"integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/java": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript": { "node_modules/@codemirror/lang-javascript": {
"version": "6.2.5", "version": "6.2.5",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
@ -189,6 +237,61 @@
"@lezer/javascript": "^1.0.0" "@lezer/javascript": "^1.0.0"
} }
}, },
"node_modules/@codemirror/lang-jinja": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz",
"integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-less": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-less/-/lang-less-6.0.2.tgz",
"integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-css": "^6.2.0",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-liquid": {
"version": "6.3.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz",
"integrity": "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.1"
}
},
"node_modules/@codemirror/lang-markdown": { "node_modules/@codemirror/lang-markdown": {
"version": "6.5.2", "version": "6.5.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
@ -204,6 +307,124 @@
"@lezer/markdown": "^1.0.0" "@lezer/markdown": "^1.0.0"
} }
}, },
"node_modules/@codemirror/lang-php": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/php": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-rust": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/rust": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sass": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz",
"integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-css": "^6.2.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/sass": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.10.0",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-vue": {
"version": "0.1.3",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz",
"integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.1"
}
},
"node_modules/@codemirror/lang-wast": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz",
"integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/xml": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": { "node_modules/@codemirror/language": {
"version": "6.12.4", "version": "6.12.4",
"resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.4.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.4.tgz",
@ -218,6 +439,46 @@
"style-mod": "^4.0.0" "style-mod": "^4.0.0"
} }
}, },
"node_modules/@codemirror/language-data": {
"version": "6.5.2",
"resolved": "https://registry.npmmirror.com/@codemirror/language-data/-/language-data-6.5.2.tgz",
"integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-angular": "^0.1.0",
"@codemirror/lang-cpp": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-go": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-java": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-jinja": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-less": "^6.0.0",
"@codemirror/lang-liquid": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-php": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/lang-rust": "^6.0.0",
"@codemirror/lang-sass": "^6.0.0",
"@codemirror/lang-sql": "^6.0.0",
"@codemirror/lang-vue": "^0.1.1",
"@codemirror/lang-wast": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lang-yaml": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/legacy-modes": "^6.4.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.3",
"resolved": "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz",
"integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": { "node_modules/@codemirror/lint": {
"version": "6.9.7", "version": "6.9.7",
"resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.7.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.7.tgz",
@ -730,6 +991,17 @@
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lezer/cpp": {
"version": "1.1.6",
"resolved": "https://registry.npmmirror.com/@lezer/cpp/-/cpp-1.1.6.tgz",
"integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/css": { "node_modules/@lezer/css": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmmirror.com/@lezer/css/-/css-1.3.6.tgz", "resolved": "https://registry.npmmirror.com/@lezer/css/-/css-1.3.6.tgz",
@ -741,6 +1013,17 @@
"@lezer/lr": "^1.3.0" "@lezer/lr": "^1.3.0"
} }
}, },
"node_modules/@lezer/go": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@lezer/go/-/go-1.0.1.tgz",
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": { "node_modules/@lezer/highlight": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz",
@ -761,6 +1044,17 @@
"@lezer/lr": "^1.0.0" "@lezer/lr": "^1.0.0"
} }
}, },
"node_modules/@lezer/java": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@lezer/java/-/java-1.1.3.tgz",
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": { "node_modules/@lezer/javascript": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz", "resolved": "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz",
@ -772,6 +1066,17 @@
"@lezer/lr": "^1.3.0" "@lezer/lr": "^1.3.0"
} }
}, },
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": { "node_modules/@lezer/lr": {
"version": "1.4.10", "version": "1.4.10",
"resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz", "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz",
@ -791,6 +1096,72 @@
"@lezer/highlight": "^1.0.0" "@lezer/highlight": "^1.0.0"
} }
}, },
"node_modules/@lezer/php": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@lezer/php/-/php-1.0.5.tgz",
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.1.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.19",
"resolved": "https://registry.npmmirror.com/@lezer/python/-/python-1.1.19.tgz",
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/rust": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@lezer/rust/-/rust-1.0.2.tgz",
"integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/sass": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@lezer/sass/-/sass-1.1.0.tgz",
"integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/@lezer/xml/-/xml-1.0.6.tgz",
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": { "node_modules/@marijn/find-cluster-break": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
@ -1557,6 +1928,12 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/lodash": { "node_modules/@types/lodash": {
"version": "4.17.24", "version": "4.17.24",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
@ -1572,6 +1949,22 @@
"@types/lodash": "*" "@types/lodash": "*"
} }
}, },
"node_modules/@types/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/sortablejs": { "node_modules/@types/sortablejs": {
"version": "1.15.9", "version": "1.15.9",
"resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", "resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz",
@ -1607,6 +2000,18 @@
"d3-transition": "^3.0.1" "d3-transition": "^3.0.1"
} }
}, },
"node_modules/@vavt/copy2clipboard": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@vavt/copy2clipboard/-/copy2clipboard-1.0.3.tgz",
"integrity": "sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==",
"license": "MIT"
},
"node_modules/@vavt/util": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/@vavt/util/-/util-2.1.2.tgz",
"integrity": "sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.4", "version": "5.2.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@ -1886,6 +2291,12 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
@ -2061,6 +2472,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@ -2122,6 +2548,12 @@
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cssfilter": {
"version": "0.0.10",
"resolved": "https://registry.npmmirror.com/cssfilter/-/cssfilter-0.0.10.tgz",
"integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
"license": "MIT"
},
"node_modules/cssom": { "node_modules/cssom": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.5.0.tgz", "resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.5.0.tgz",
@ -3513,6 +3945,25 @@
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/local-pkg": { "node_modules/local-pkg": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz",
@ -3536,6 +3987,24 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lucide-vue-next": {
"version": "0.453.0",
"resolved": "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.453.0.tgz",
"integrity": "sha512-5zmv83vxAs9SVoe22veDBi8Dw0Fh2F+oTngWgKnKOkrZVbZjceXLQ3tescV2boB0zlaf9R2Sd9RuUP2766xvsQ==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@ -3545,12 +4014,75 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/markdown-it": {
"version": "14.3.0",
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.3.0.tgz",
"integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.5.0",
"linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-fence": { "node_modules/markdown-it-fence": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmmirror.com/markdown-it-fence/-/markdown-it-fence-0.1.3.tgz", "resolved": "https://registry.npmmirror.com/markdown-it-fence/-/markdown-it-fence-0.1.3.tgz",
"integrity": "sha512-mdZbkwJUyZXhyDX4QzD+WnIhxWBq9oIIJU22E6jCT7MHgIp79oEOJfwXIl/HqmfIxnXEdC47sBGn4h6chM4DKw==", "integrity": "sha512-mdZbkwJUyZXhyDX4QzD+WnIhxWBq9oIIJU22E6jCT7MHgIp79oEOJfwXIl/HqmfIxnXEdC47sBGn4h6chM4DKw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/markdown-it-image-figures": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/markdown-it-image-figures/-/markdown-it-image-figures-2.1.1.tgz",
"integrity": "sha512-mwXSQ2nPeVUzCMIE3HlLvjRioopiqyJLNph0pyx38yf9mpqFDhNGnMpAXF9/A2Xv0oiF2cVyg9xwfF0HNAz05g==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"markdown-it": "*"
}
},
"node_modules/markdown-it-sub": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/markdown-it-sub/-/markdown-it-sub-2.0.0.tgz",
"integrity": "sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==",
"license": "MIT"
},
"node_modules/markdown-it-sup": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/markdown-it-sup/-/markdown-it-sup-2.0.0.tgz",
"integrity": "sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==",
"license": "MIT"
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/marked": { "node_modules/marked": {
"version": "12.0.2", "version": "12.0.2",
"resolved": "https://registry.npmmirror.com/marked/-/marked-12.0.2.tgz", "resolved": "https://registry.npmmirror.com/marked/-/marked-12.0.2.tgz",
@ -3581,6 +4113,50 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/md-editor-v3": {
"version": "5.8.5",
"resolved": "https://registry.npmmirror.com/md-editor-v3/-/md-editor-v3-5.8.5.tgz",
"integrity": "sha512-NsqAmmAx/ykA1AcwxcHH4Hkn4VAPkqMX7Hd6Lv4FcwQoMQ70wWmJfs/mokyPGkqr4oYqqn8LRMBTqFNfoP0O0A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/language": "^6.11.0",
"@codemirror/language-data": "^6.5.1",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.8",
"@lezer/highlight": "^1.2.1",
"@types/markdown-it": "^14.0.1",
"@vavt/copy2clipboard": "^1.0.1",
"@vavt/util": "^2.1.0",
"codemirror": "^6.0.1",
"lru-cache": "^11.0.1",
"lucide-vue-next": "^0.453.0",
"markdown-it": "^14.0.0",
"markdown-it-image-figures": "^2.1.1",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"medium-zoom": "^1.1.0",
"xss": "^1.0.15"
},
"peerDependencies": {
"vue": "^3.5.3"
}
},
"node_modules/mdurl": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.1.0.tgz",
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
"license": "MIT"
},
"node_modules/medium-zoom": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/medium-zoom/-/medium-zoom-1.1.0.tgz",
"integrity": "sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ==",
"license": "MIT"
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
@ -3966,6 +4542,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
@ -4419,6 +5004,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/ufo": { "node_modules/ufo": {
"version": "1.6.4", "version": "1.6.4",
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
@ -4908,6 +5499,28 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/xss": {
"version": "1.0.15",
"resolved": "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz",
"integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==",
"license": "MIT",
"dependencies": {
"commander": "^2.20.3",
"cssfilter": "0.0.10"
},
"bin": {
"xss": "bin/xss"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/xss/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",

1
frontend/package.json

@ -15,6 +15,7 @@
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"marked": "^12.0.2", "marked": "^12.0.2",
"md-editor-v3": "^5.0.0",
"mermaid": "^11.4.1", "mermaid": "^11.4.1",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"tdesign-icons-vue-next": "^0.4.9", "tdesign-icons-vue-next": "^0.4.9",

2
frontend/src/api/chat.ts

@ -20,6 +20,7 @@ export interface ChatOptions {
rewriteStrategy?: string rewriteStrategy?: string
categoryId?: string | number categoryId?: string | number
categoryIds?: string[] categoryIds?: string[]
imageUrls?: string[]
} }
/** /**
@ -45,6 +46,7 @@ function buildChatQueryParams(
if (options.rewriteStrategy) params.set('rewriteStrategy', options.rewriteStrategy) if (options.rewriteStrategy) params.set('rewriteStrategy', options.rewriteStrategy)
if (options.categoryId !== undefined && options.categoryId !== '') params.set('categoryId', String(options.categoryId)) if (options.categoryId !== undefined && options.categoryId !== '') params.set('categoryId', String(options.categoryId))
if (options.categoryIds && options.categoryIds.length > 0) params.set('categoryIds', options.categoryIds.join(',')) if (options.categoryIds && options.categoryIds.length > 0) params.set('categoryIds', options.categoryIds.join(','))
if (options.imageUrls && options.imageUrls.length > 0) params.set('imageUrls', options.imageUrls.map(u => encodeURIComponent(u)).join(','))
} }
return params return params
} }

5
frontend/src/api/upload.ts

@ -47,3 +47,8 @@ export function uploadJsonFields(formData: FormData, fields: string, onProgress?
export function uploadJsonPointer(formData: FormData, pointer: string, onProgress?: (pct: number) => void): Promise<ApiResponse> { export function uploadJsonPointer(formData: FormData, pointer: string, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData, onProgress) return postFormWithProgress(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData, onProgress)
} }
/** 上传图片/附件到 SFTP,返回可公开访问的 URL(data 内含 url/name/type/mimeType/size) */
export function uploadAttachment(formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress('/attachment/upload', formData, onProgress)
}

51
frontend/src/components/FormDrawer.vue

@ -0,0 +1,51 @@
<template>
<!--
通用新增/编辑抽屉表单壳组件
封装 t-drawer + 页脚按钮行表单内容通过 slot 注入
适用于富文本/宽内容表单Markdown 编辑器宽预览长文本宽表格编辑
-->
<t-drawer
:visible="visible"
:header="title"
:size="size"
:close-on-overlay-click="!saving"
@close="handleClose"
>
<slot />
<template #footer>
<div class="dialog-footer">
<t-button variant="outline" :disabled="saving" @click="handleClose">取消</t-button>
<slot name="extra-buttons" />
<t-button theme="primary" :loading="saving" :disabled="disableConfirm" @click="$emit('confirm')">{{ confirmText }}</t-button>
</div>
</template>
</t-drawer>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: '' },
size: { type: String, default: 'large' },
saving: { type: Boolean, default: false },
confirmText: { type: String, default: '保存' },
disableConfirm: { type: Boolean, default: false },
// false Promise<false> "退"
beforeClose: { type: Function as PropType<() => boolean | Promise<boolean>>, default: null },
})
const emit = defineEmits<{
'update:visible': [value: boolean]
confirm: []
}>()
async function handleClose() {
if (props.beforeClose) {
const ok = await props.beforeClose()
if (!ok) return
}
emit('update:visible', false)
}
</script>

10
frontend/src/utils/chatAdapter.ts

@ -8,6 +8,15 @@
import type { TdChatItemMeta, AIMessageContent, UserMessageContent } from '@tdesign-vue-next/chat' import type { TdChatItemMeta, AIMessageContent, UserMessageContent } from '@tdesign-vue-next/chat'
/** 附件信息(图片或文件) */
export interface Attachment {
name: string
url: string
type: 'image' | 'file'
mimeType?: string
size?: number
}
/** 项目内部消息模型(与 ChatPanel.vue 中 ChatMessage 保持一致) */ /** 项目内部消息模型(与 ChatPanel.vue 中 ChatMessage 保持一致) */
export interface ChatMessage { export interface ChatMessage {
id: string id: string
@ -19,6 +28,7 @@ export interface ChatMessage {
toolCalls?: any[] toolCalls?: any[]
error?: boolean error?: boolean
feedback?: string | null feedback?: string | null
attachments?: Attachment[]
} }
/** 转换单条消息 */ /** 转换单条消息 */

101
frontend/src/views/ChatPanel.vue

@ -113,7 +113,22 @@
<!-- AI 思考中 --> <!-- AI 思考中 -->
<div v-else-if="messages[index]?.role === 'assistant'" class="thinking"><span>正在思考</span></div> <div v-else-if="messages[index]?.role === 'assistant'" class="thinking"><span>正在思考</span></div>
<!-- 用户消息 --> <!-- 用户消息 -->
<div v-else class="user-text">{{ messages[index]?.content }}</div>
<template v-else>
<div class="user-text">{{ messages[index]?.content }}</div>
<div v-if="messages[index]?.attachments?.length" class="user-attachments">
<a
v-for="att in messages[index].attachments"
:key="att.url"
:href="att.url"
target="_blank"
rel="noopener"
class="user-attachment"
>
<img v-if="att.type === 'image'" :src="att.url" :alt="att.name" class="user-attachment-img" />
<span v-else class="user-attachment-file">📄 {{ att.name }}</span>
</a>
</div>
</template>
<!-- 引用来源 --> <!-- 引用来源 -->
<MessageSources v-if="messages[index]?.role === 'assistant' && messages[index]?.sources?.length" :sources="messages[index].sources" /> <MessageSources v-if="messages[index]?.role === 'assistant' && messages[index]?.sources?.length" :sources="messages[index].sources" />
@ -154,11 +169,30 @@
<!-- 输入区 --> <!-- 输入区 -->
<footer class="chat-composer"> <footer class="chat-composer">
<!-- 待发送附件预览 -->
<div v-if="pendingAttachments.length" class="pending-attachments">
<div v-for="(att, i) in pendingAttachments" :key="att.url" class="pending-attachment">
<img v-if="att.type === 'image'" :src="att.url" :alt="att.name" class="pending-thumb" />
<span v-else class="pending-file">📄 {{ att.name }}</span>
<span class="pending-remove" @click="removePendingAttachment(i)"></span>
</div>
</div>
<div class="composer-toolbar">
<t-button variant="text" size="small" :disabled="isSending" @click="triggerFileInput">📎 图片/附件</t-button>
<input
ref="fileInputRef"
type="file"
multiple
accept="image/*,.pdf,.zip,.doc,.docx,.xls,.xlsx,.txt,.csv"
style="display:none"
@change="onFilesSelected"
/>
</div>
<chat-sender <chat-sender
v-model="userInput" v-model="userInput"
:loading="isSending" :loading="isSending"
:disabled="isSending" :disabled="isSending"
:send-btn-disabled="!userInput.trim()"
:send-btn-disabled="!userInput.trim() && !pendingAttachments.length"
placeholder="输入问题,Enter 发送,Shift + Enter 换行" placeholder="输入问题,Enter 发送,Shift + Enter 换行"
:textarea-props="{ autosize: { minRows: 2, maxRows: 4 } }" :textarea-props="{ autosize: { minRows: 2, maxRows: 4 } }"
@send="send" @send="send"
@ -206,12 +240,13 @@ import { getRoleList } from '@/api/role'
import { getActiveModelConfig } from '@/api/model-config' import { getActiveModelConfig } from '@/api/model-config'
import { truncateConversation } from '@/api/conversation' import { truncateConversation } from '@/api/conversation'
import { submitFeedback as submitFeedbackApi } from '@/api/feedback' import { submitFeedback as submitFeedbackApi } from '@/api/feedback'
import { uploadAttachment } from '@/api/upload'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { readSSEStream, readSSEStreamWithEvents } from '@/utils/sse' import { readSSEStream, readSSEStreamWithEvents } from '@/utils/sse'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { toChatData, isLastAssistant } from '@/utils/chatAdapter' import { toChatData, isLastAssistant } from '@/utils/chatAdapter'
import type { ChatMessage } from '@/utils/chatAdapter'
import type { ChatMessage, Attachment } from '@/utils/chatAdapter'
import MessageSources from '@/components/MessageSources.vue' import MessageSources from '@/components/MessageSources.vue'
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
@ -255,6 +290,8 @@ const modelLoadError = ref('')
const userInput = ref('') const userInput = ref('')
const lastUserInput = ref('') const lastUserInput = ref('')
const isSending = ref(false) const isSending = ref(false)
const pendingAttachments = ref<Attachment[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const msgAreaRef = ref<HTMLElement | null>(null) const msgAreaRef = ref<HTMLElement | null>(null)
const editingIndex = ref(-1) const editingIndex = ref(-1)
const editingText = ref('') const editingText = ref('')
@ -406,20 +443,58 @@ function useQuickQuestion(question: string): void {
send() send()
} }
// ==================== ====================
function triggerFileInput(): void {
fileInputRef.value?.click()
}
async function onFilesSelected(e: Event): Promise<void> {
const input = e.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = '' //
if (!files.length) return
for (const file of files) {
try {
const fd = new FormData()
fd.append('file', file)
const res: any = await uploadAttachment(fd)
if (res?.success && res?.data?.url) {
pendingAttachments.value.push({
name: file.name,
url: res.data.url,
type: res.data.type === 'file' ? 'file' : 'image',
mimeType: res.data.mimeType,
size: res.data.size,
})
} else {
toast(`上传失败(${file.name}):` + (res?.message || '未知错误'), 'error')
}
} catch (e: any) {
toast(`附件上传失败(${file.name}):` + e.message, 'error')
}
}
}
function removePendingAttachment(index: number): void {
pendingAttachments.value.splice(index, 1)
}
// ==================== ==================== // ==================== ====================
async function send(): Promise<void> { async function send(): Promise<void> {
const text = userInput.value.trim() const text = userInput.value.trim()
if (!text || isSending.value) return
const attachments = [...pendingAttachments.value]
if ((!text && !attachments.length) || isSending.value) return
userInput.value = '' userInput.value = ''
pendingAttachments.value = []
lastUserInput.value = text lastUserInput.value = text
isSending.value = true isSending.value = true
currentSuggestions.value = [] // currentSuggestions.value = [] //
//
//
messages.value.push({ messages.value.push({
id: generateMsgId(), role: 'user', content: text, id: generateMsgId(), role: 'user', content: text,
streaming: false, time: formatTime(),
streaming: false, time: formatTime(), attachments,
}) })
// AI // AI
@ -440,11 +515,13 @@ async function send(): Promise<void> {
const signal = sseAbortController.signal const signal = sseAbortController.signal
// RAG enableRag // RAG enableRag
const imageUrls = attachments.filter(a => a.type === 'image').map(a => a.url)
const chatOptions: ChatOptions = { const chatOptions: ChatOptions = {
roleId: currentRoleId(), roleId: currentRoleId(),
enableRag: isRagMode.value, enableRag: isRagMode.value,
rewriteStrategy: isRagMode.value ? ragStrategy.value : undefined, rewriteStrategy: isRagMode.value ? ragStrategy.value : undefined,
categoryIds: selectedCategoryIds.value, categoryIds: selectedCategoryIds.value,
imageUrls: imageUrls.length ? imageUrls : undefined,
} }
if (mode.value === 'sync') { if (mode.value === 'sync') {
@ -836,6 +913,12 @@ onBeforeUnmount(() => {
.chat-custom-content :deep(code) { background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 3px; font-size: 13px; } .chat-custom-content :deep(code) { background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 3px; font-size: 13px; }
.chat-custom-content :deep(pre) { background: var(--color-code-block-bg); color: var(--color-code-block-text); padding: 12px; border-radius: 8px; overflow-x: auto; } .chat-custom-content :deep(pre) { background: var(--color-code-block-bg); color: var(--color-code-block-text); padding: 12px; border-radius: 8px; overflow-x: auto; }
.chat-custom-content :deep(pre code) { background: none; padding: 0; color: inherit; } .chat-custom-content :deep(pre code) { background: none; padding: 0; color: inherit; }
.chat-custom-content :deep(img) { max-width: 100%; border-radius: 8px; }
/* 用户消息附件 */
.user-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.user-attachment { display: block; }
.user-attachment-img { max-width: 160px; max-height: 160px; border-radius: 8px; object-fit: cover; display: block; }
.user-attachment-file { display: inline-block; max-width: 200px; padding: 6px 10px; border-radius: 6px; background: rgba(255,255,255,.15); color: var(--td-text-color-anti); font-size: 13px; word-break: break-all; }
/* ==================== MCP 工具调用 ==================== */ /* ==================== MCP 工具调用 ==================== */
.mcp-tool-calls { margin-bottom: 8px; } .mcp-tool-calls { margin-bottom: 8px; }
@ -856,6 +939,12 @@ onBeforeUnmount(() => {
/* ==================== 输入区 ==================== */ /* ==================== 输入区 ==================== */
.chat-composer { padding: 12px 16px; border-top: 1px solid var(--td-border-level-1-color); } .chat-composer { padding: 12px 16px; border-top: 1px solid var(--td-border-level-1-color); }
.composer-toolbar { padding: 0 0 4px; }
.pending-attachments { display: flex; flex-wrap: wrap; gap: 8px; padding: 4px 0 8px; }
.pending-attachment { position: relative; display: inline-flex; align-items: center; }
.pending-thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; border: 1px solid var(--td-border-level-1-color); }
.pending-file { max-width: 160px; padding: 4px 8px; border-radius: 6px; background: var(--td-bg-color-component); font-size: 12px; color: var(--td-text-color-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pending-remove { position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; line-height: 16px; text-align: center; border-radius: 50%; background: var(--td-error-color); color: #fff; font-size: 10px; cursor: pointer; }
@media (max-width: 768px) { @media (max-width: 768px) {
.chat-sidebar { display: none; } .chat-sidebar { display: none; }

76
frontend/src/views/FaqManager.vue

@ -34,11 +34,18 @@
</template> </template>
</t-table> </t-table>
<!-- 新增/编辑弹窗 -->
<t-dialog v-model:visible="showFormDialog" :header="editingFaq ? '编辑 FAQ' : '添加 FAQ'" width="720px" :footer="false">
<!-- 新增/编辑抽屉富文本 Markdown 编辑器需宽幅用抽屉替代窄弹窗 -->
<FormDrawer
v-model:visible="showFormDialog"
:title="editingFaq ? '编辑 FAQ' : '添加 FAQ'"
size="1200px"
:disable-confirm="!form.question || !form.answer"
:before-close="beforeCloseDrawer"
@confirm="saveFaq"
>
<t-form label-align="top"> <t-form label-align="top">
<t-form-item label="问题 *"><t-input v-model="form.question" placeholder="输入标准问题" /></t-form-item> <t-form-item label="问题 *"><t-input v-model="form.question" placeholder="输入标准问题" /></t-form-item>
<t-form-item label="标准答案 *"><t-textarea v-model="form.answer" :autosize="{ minRows: 3, maxRows: 8 }" placeholder="输入标准答案" /></t-form-item>
<t-form-item label="标准答案 *"><MdEditor v-model="form.answer" :on-upload-img="handleUploadImg" style="height: 460px;" placeholder="输入标准答案(支持粘贴图片)" /></t-form-item>
<t-form-item label="相似问题(每行一个)"><t-textarea v-model="form.similarQuestionsText" :autosize="{ minRows: 3, maxRows: 5 }" placeholder="每行一个相似问法" /></t-form-item> <t-form-item label="相似问题(每行一个)"><t-textarea v-model="form.similarQuestionsText" :autosize="{ minRows: 3, maxRows: 5 }" placeholder="每行一个相似问法" /></t-form-item>
<t-row :gutter="16"> <t-row :gutter="16">
<t-col :span="6"> <t-col :span="6">
@ -49,11 +56,7 @@
</t-col> </t-col>
</t-row> </t-row>
</t-form> </t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="showFormDialog = false">取消</t-button>
<t-button theme="primary" @click="saveFaq" :disabled="!form.question||!form.answer">保存</t-button>
</div>
</t-dialog>
</FormDrawer>
<!-- 批量导入弹窗 --> <!-- 批量导入弹窗 -->
<t-dialog v-model:visible="showImportDialog" header="批量导入 FAQ" width="660px" :footer="false"> <t-dialog v-model:visible="showImportDialog" header="批量导入 FAQ" width="660px" :footer="false">
@ -74,6 +77,10 @@ import { getCategoryTree } from '@/api/category'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useDebounce } from '@/composables/useDebounce' import { useDebounce } from '@/composables/useDebounce'
import { uploadAttachment } from '@/api/upload'
import FormDrawer from '@/components/FormDrawer.vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const { debounce } = useDebounce() const { debounce } = useDebounce()
@ -85,6 +92,7 @@ const searchKeyword = ref(''); const filterCategoryId = ref(''); const filterSta
const stats = ref<any>(null) const stats = ref<any>(null)
const showFormDialog = ref(false); const showImportDialog = ref(false); const editingFaq = ref<any>(null) const showFormDialog = ref(false); const showImportDialog = ref(false); const editingFaq = ref<any>(null)
const form = ref({ question: '', answer: '', similarQuestionsText: '', categoryId: null as any, priority: 0 }) const form = ref({ question: '', answer: '', similarQuestionsText: '', categoryId: null as any, priority: 0 })
const initialForm = ref({ question: '', answer: '', similarQuestionsText: '', categoryId: null as any, priority: 0 })
const importJson = ref('') const importJson = ref('')
const flatCategories = ref<any[]>([]); const categoryMap = ref<Record<string, string>>({}) const flatCategories = ref<any[]>([]); const categoryMap = ref<Record<string, string>>({})
@ -93,9 +101,40 @@ function sourceLabel(s: string): string {
return map[s] || s || '-' return map[s] || s || '-'
} }
/** 去除 markdown 语法得到纯文本摘要(用于表格列展示) */
function stripMarkdown(md: string): string {
if (!md) return ''
return md
.replace(/!\[[^\]]*\]\([^)]*\)/g, '') //
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') //
.replace(/[#>*`~_-]+/g, '') //
.replace(/\n+/g, ' ')
.trim()
}
/** md-editor-v3 粘贴/拖拽图片上传回调:上传到 SFTP 后回填 URL,由编辑器自动插入 ![](url) */
async function handleUploadImg(files: File[], callback: (urls: string[]) => void) {
const urls: string[] = []
for (const file of files) {
try {
const fd = new FormData()
fd.append('file', file)
const res: any = await uploadAttachment(fd)
if (res?.success && res?.data?.url) {
urls.push(res.data.url)
} else {
toast(`图片上传失败(${file.name}):` + (res?.message || '未知错误'), 'error')
}
} catch (e: any) {
toast(`图片上传失败(${file.name}):` + e.message, 'error')
}
}
callback(urls)
}
const columns = [ const columns = [
{ colKey: 'question', title: '问题', width: 200, ellipsis: true }, { colKey: 'question', title: '问题', width: 200, ellipsis: true },
{ colKey: 'answer', title: '答案(摘要)', width: 200, ellipsis: true },
{ colKey: 'answer', title: '答案(摘要)', width: 200, ellipsis: true, cell: (_h: any, { row }: any) => stripMarkdown(row.answer || '') },
{ colKey: 'categoryName', title: '分类', width: 100, cell: (_h:any,{row}:any)=>categoryMap.value[row.categoryId]||'-' }, { colKey: 'categoryName', title: '分类', width: 100, cell: (_h:any,{row}:any)=>categoryMap.value[row.categoryId]||'-' },
{ colKey: 'source', title: '来源', width: 70, cell: (_h:any,{row}:any)=>sourceLabel(row.source) }, { colKey: 'source', title: '来源', width: 70, cell: (_h:any,{row}:any)=>sourceLabel(row.source) },
{ colKey: 'priority', title: '优先级', width: 60 }, { colKey: 'priority', title: '优先级', width: 60 },
@ -129,11 +168,26 @@ async function loadStats() { try { const r = await getFaqStats(); if(r.success)
function onPageChange(info:{current:number;pageSize:number}) { page.value=info.current; pageSize.value=info.pageSize; loadList() } function onPageChange(info:{current:number;pageSize:number}) { page.value=info.current; pageSize.value=info.pageSize; loadList() }
const debouncedSearch = debounce(() => { page.value = 1; loadList() }) const debouncedSearch = debounce(() => { page.value = 1; loadList() })
function openAddDialog() { editingFaq.value=null; form.value={question:'',answer:'',similarQuestionsText:'',categoryId:null,priority:0}; showFormDialog.value=true }
/** 表单当前值快照,用于判断是否有未保存修改 */
function snapshotForm() {
return { question: form.value.question, answer: form.value.answer, similarQuestionsText: form.value.similarQuestionsText, categoryId: form.value.categoryId, priority: form.value.priority }
}
/** 是否有未保存的修改(与打开时快照对比) */
function isDirty() {
const a = form.value, b = initialForm.value
return a.question !== b.question || a.answer !== b.answer || a.similarQuestionsText !== b.similarQuestionsText || a.categoryId !== b.categoryId || a.priority !== b.priority
}
/** 抽屉关闭前拦截:有未保存修改时二次确认 */
async function beforeCloseDrawer(): Promise<boolean> {
if (!isDirty()) return true
return confirm('当前修改尚未保存,确定要退出吗?', '退出后未保存的内容将丢失')
}
function openAddDialog() { editingFaq.value=null; form.value={question:'',answer:'',similarQuestionsText:'',categoryId:null,priority:0}; initialForm.value=snapshotForm(); showFormDialog.value=true }
function openEditDialog(f:any) { function openEditDialog(f:any) {
editingFaq.value = f editingFaq.value = f
let st = ''; try { const a = typeof f.similarQuestions==='string'?JSON.parse(f.similarQuestions):f.similarQuestions; st=Array.isArray(a)?a.join('\n'):'' } catch { st=f.similarQuestions||'' } let st = ''; try { const a = typeof f.similarQuestions==='string'?JSON.parse(f.similarQuestions):f.similarQuestions; st=Array.isArray(a)?a.join('\n'):'' } catch { st=f.similarQuestions||'' }
form.value = { question:f.question, answer:f.answer, similarQuestionsText:st, categoryId:f.categoryId||null, priority:f.priority||0 }; showFormDialog.value=true
form.value = { question:f.question, answer:f.answer, similarQuestionsText:st, categoryId:f.categoryId||null, priority:f.priority||0 }; initialForm.value=snapshotForm(); showFormDialog.value=true
} }
async function saveFaq() { async function saveFaq() {

6
pom.xml

@ -104,6 +104,12 @@
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
<version>5.8.37</version> <version>5.8.37</version>
</dependency> </dependency>
<!-- SFTP 客户端(附件上传到 SFTP 服务器) -->
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
<!--Knife4j 接口文档--> <!--Knife4j 接口文档-->
<dependency> <dependency>
<groupId>com.github.xiaoymin</groupId> <groupId>com.github.xiaoymin</groupId>

18
src/main/java/com/wok/supportbot/advisor/ContentSafetyAdvisor.java

@ -11,6 +11,7 @@ import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.content.Media;
import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.Prompt;
@ -105,7 +106,7 @@ public class ContentSafetyAdvisor implements BaseAdvisor {
log.info("内容安全脱敏(MASK)- 会话:{},命中词:{}", sessionId, log.info("内容安全脱敏(MASK)- 会话:{},命中词:{}", sessionId,
hits.stream().map(ContentSafetyService.HitResult::getWord).collect(Collectors.joining(", "))); hits.stream().map(ContentSafetyService.HitResult::getWord).collect(Collectors.joining(", ")));
messages.set(lastUserIndex, new UserMessage(maskedText));
messages.set(lastUserIndex, preserveMedia(maskedText, (UserMessage) messages.get(lastUserIndex)));
Prompt newPrompt = new Prompt(messages, originalPrompt.getOptions()); Prompt newPrompt = new Prompt(messages, originalPrompt.getOptions());
saveAuditLog(sessionId, "INPUT", truncate(userText), hits, "MASK"); saveAuditLog(sessionId, "INPUT", truncate(userText), hits, "MASK");
@ -121,6 +122,21 @@ public class ContentSafetyAdvisor implements BaseAdvisor {
} }
} }
/**
* 脱敏替换用户消息时保留原消息的图片 Media多模态图片输入
* MASK 场景使用BLOCK 场景刻意丢弃图片整条消息被拦截
*/
private UserMessage preserveMedia(String text, UserMessage original) {
List<Media> media = original.getMedia();
if (media == null || media.isEmpty()) {
return new UserMessage(text);
}
return UserMessage.builder()
.text(text)
.media(media)
.build();
}
// ==================== after检查 AI 输出 ==================== // ==================== after检查 AI 输出 ====================
@Override @Override

63
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -19,6 +19,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.content.Media;
import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.ChatResponse;
@ -27,6 +29,8 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import reactor.core.Disposable; import reactor.core.Disposable;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@ -237,6 +241,59 @@ public class AssistantApp {
log.info("AssistantApp ChatClient cache cleared"); log.info("AssistantApp ChatClient cache cleared");
} }
/**
* 构造传给模型的用户消息无图片时返回纯文本 UserMessage
* 有图片时构造多模态 UserMessage文本 + 图片 Media
*/
private Message buildUserMessage(ChatRequest req) {
List<String> imageUrls = req.ctx().imageUrls();
if (imageUrls == null || imageUrls.isEmpty()) {
return new UserMessage(req.finalMessage());
}
List<Media> mediaList = imageUrls.stream()
.map(this::toImageMedia)
.filter(m -> m != null)
.toList();
if (mediaList.isEmpty()) {
return new UserMessage(req.finalMessage());
}
return UserMessage.builder()
.text(req.finalMessage())
.media(mediaList)
.build();
}
/** 将图片 URL 转换为 Media(MIME 类型按扩展名推断),URL 非法时返回 null */
private Media toImageMedia(String url) {
try {
return Media.builder()
.mimeType(resolveImageMimeType(url))
.data(url)
.build();
} catch (Exception e) {
log.warn("图片 URL 非法,跳过: {}", url);
return null;
}
}
/** 根据图片 URL 扩展名推断 MIME 类型 */
private MimeType resolveImageMimeType(String url) {
String path = url;
int q = path.indexOf('?');
if (q >= 0) {
path = path.substring(0, q);
}
int dot = path.lastIndexOf('.');
if (dot < 0) {
return MimeTypeUtils.IMAGE_PNG;
}
String ext = path.substring(dot + 1).toLowerCase();
if ("jpg".equals(ext)) {
ext = "jpeg";
}
return MimeTypeUtils.parseMimeType("image/" + ext);
}
// ==================== 统一入口 ==================== // ==================== 统一入口 ====================
/** /**
@ -278,7 +335,7 @@ public class AssistantApp {
try { try {
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools()) ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt() .prompt()
.user(req.finalMessage())
.messages(buildUserMessage(req))
.advisors(s -> s.param(CONVERSATION_ID, ctx.chatId())); .advisors(s -> s.param(CONVERSATION_ID, ctx.chatId()));
if (StringUtils.hasText(req.finalSystemPrompt())) { if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt()); spec = spec.system(req.finalSystemPrompt());
@ -341,7 +398,7 @@ public class AssistantApp {
AtomicInteger rounds = new AtomicInteger(0); AtomicInteger rounds = new AtomicInteger(0);
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools()) ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt() .prompt()
.user(req.finalMessage())
.messages(buildUserMessage(req))
.advisors(s -> s.param(CONVERSATION_ID, ctx.chatId())); .advisors(s -> s.param(CONVERSATION_ID, ctx.chatId()));
if (StringUtils.hasText(req.finalSystemPrompt())) { if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt()); spec = spec.system(req.finalSystemPrompt());
@ -436,7 +493,7 @@ public class AssistantApp {
AtomicInteger rounds = new AtomicInteger(0); AtomicInteger rounds = new AtomicInteger(0);
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools()) ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt() .prompt()
.user(req.finalMessage())
.messages(buildUserMessage(req))
.advisors(s -> s.param(CONVERSATION_ID, ctx.chatId())); .advisors(s -> s.param(CONVERSATION_ID, ctx.chatId()));
if (StringUtils.hasText(req.finalSystemPrompt())) { if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt()); spec = spec.system(req.finalSystemPrompt());

26
src/main/java/com/wok/supportbot/app/ChatContext.java

@ -24,6 +24,7 @@ import java.util.List;
* @param roleName 客服角色名可空快照用途 * @param roleName 客服角色名可空快照用途
* @param accountId 账户 ID可空供调用追踪/个人信息删除权使用 * @param accountId 账户 ID可空供调用追踪/个人信息删除权使用
* @param apiKeyId API Key ID可空Open API 路径为实际鉴权 Key供租户隔离与追踪 * @param apiKeyId API Key ID可空Open API 路径为实际鉴权 Key供租户隔离与追踪
* @param imageUrls 用户消息携带的图片访问 URL 列表多模态输入可为 null/
*/ */
public record ChatContext( public record ChatContext(
String message, String message,
@ -38,7 +39,8 @@ public record ChatContext(
Long roleId, Long roleId,
String roleName, String roleName,
String accountId, String accountId,
Long apiKeyId
Long apiKeyId,
List<String> imageUrls
) { ) {
/** 默认应用类型 */ /** 默认应用类型 */
@ -57,38 +59,42 @@ public record ChatContext(
* 便捷构造仅指定核心字段其余取默认值 RAG非流式 * 便捷构造仅指定核心字段其余取默认值 RAG非流式
*/ */
public static ChatContext of(String message, String chatId) { public static ChatContext of(String message, String chatId) {
return new ChatContext(message, chatId, DEFAULT_APP_TYPE, null, null, null, null, false, false, null, null, null, null);
return new ChatContext(message, chatId, DEFAULT_APP_TYPE, null, null, null, null, false, false, null, null, null, null, null);
} }
public ChatContext withSystemPrompt(String systemPrompt) { public ChatContext withSystemPrompt(String systemPrompt) {
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId);
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId, imageUrls);
} }
public ChatContext withAllowedMcpTools(List<String> allowedMcpTools) { public ChatContext withAllowedMcpTools(List<String> allowedMcpTools) {
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId);
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId, imageUrls);
} }
public ChatContext withCategoryIds(List<Long> categoryIds) { public ChatContext withCategoryIds(List<Long> categoryIds) {
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId);
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId, imageUrls);
} }
public ChatContext withRewriteStrategy(String rewriteStrategy) { public ChatContext withRewriteStrategy(String rewriteStrategy) {
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId);
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId, imageUrls);
} }
public ChatContext withEnableRag(boolean enableRag) { public ChatContext withEnableRag(boolean enableRag) {
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId);
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId, imageUrls);
} }
public ChatContext withStreaming(boolean streaming) { public ChatContext withStreaming(boolean streaming) {
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId);
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId, imageUrls);
} }
public ChatContext withAccountId(String accountId) { public ChatContext withAccountId(String accountId) {
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId);
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId, imageUrls);
} }
public ChatContext withApiKeyId(Long apiKeyId) { public ChatContext withApiKeyId(Long apiKeyId) {
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId);
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId, imageUrls);
}
public ChatContext withImageUrls(List<String> imageUrls) {
return new ChatContext(message, chatId, appType, systemPrompt, allowedMcpTools, categoryIds, rewriteStrategy, enableRag, streaming, roleId, roleName, accountId, apiKeyId, imageUrls);
} }
} }

69
src/main/java/com/wok/supportbot/config/StorageProperties.java

@ -0,0 +1,69 @@
package com.wok.supportbot.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
import java.util.List;
/**
* 附件存储配置SFTP 上传 + 上传校验白名单
* <p>
* 对应 application.yml {@code storage.sftp.*} / {@code storage.upload.*}集中读取
* 支持环境变量覆盖避免散落 {@code @Value} 硬编码
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "storage")
public class StorageProperties {
/** SFTP 服务器配置 */
private Sftp sftp = new Sftp();
/** 上传校验配置 */
private Upload upload = new Upload();
/**
* SFTP 服务器配置
*/
@Getter
@Setter
public static class Sftp {
/** SFTP 主机 */
private String host = "localhost";
/** SFTP 端口 */
private int port = 22;
/** 用户名 */
private String username = "";
/** 密码 */
private String password = "";
/** SFTP 根目录 */
private String basePath = "/uploads";
/** 对外访问域名前缀(HTTPS 网关),为空时回退 sftp:// 形式 */
private String domain = "";
/** 业务目录前缀 */
private String bizId = "chat-bot";
/** 连接超时(支持 `10s` / `10000ms` 等 Duration 写法) */
private Duration connectTimeout = Duration.ofSeconds(10);
/** 通道超时(支持 `10s` / `10000ms` 等 Duration 写法) */
private Duration channelTimeout = Duration.ofSeconds(15);
}
/**
* 上传校验配置
*/
@Getter
@Setter
public static class Upload {
/** 图片白名单扩展名 */
private List<String> imageExtensions = List.of("jpg", "jpeg", "png", "gif", "webp", "bmp");
/** 附件白名单扩展名 */
private List<String> fileExtensions = List.of("pdf", "zip", "doc", "docx", "xls", "xlsx", "txt", "csv");
/** 文件大小上限,默认 50MB */
private DataSize maxSize = DataSize.ofMegabytes(50);
}
}

38
src/main/java/com/wok/supportbot/controller/AiController.java

@ -26,7 +26,10 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@ -118,7 +121,7 @@ public class AiController {
try { try {
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds); List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
ChatContext ctx = new ChatContext(message, chatId, "CHAT", null, null, cats, ChatContext ctx = new ChatContext(message, chatId, "CHAT", null, null, cats,
normalizeStrategy(rewriteStrategy), true, false, context.roleId(), scope.name(), context.accountId(), null);
normalizeStrategy(rewriteStrategy), true, false, context.roleId(), scope.name(), context.accountId(), null, null);
List<Document> docs = assistantApp.retrieveSources(ctx); List<Document> docs = assistantApp.retrieveSources(ctx);
List<Map<String, Object>> out = new ArrayList<>(); List<Map<String, Object>> out = new ArrayList<>();
for (Document doc : docs) { for (Document doc : docs) {
@ -149,7 +152,7 @@ public class AiController {
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return new ChatContext(message, chatId, "CHAT", resolveSystemPrompt(scope, systemPrompt), return new ChatContext(message, chatId, "CHAT", resolveSystemPrompt(scope, systemPrompt),
scope.hasRole() ? scope.allowedMcpTools() : null, null, null, false, false, scope.hasRole() ? scope.allowedMcpTools() : null, null, null, false, false,
context.roleId(), scope.name(), context.accountId(), null);
context.roleId(), scope.name(), context.accountId(), null, null);
} }
/** 构造 RAG 对话的 ChatContext(含严格隔离判断:KbDenied 则 enableRag=false)。 */ /** 构造 RAG 对话的 ChatContext(含严格隔离判断:KbDenied 则 enableRag=false)。 */
@ -162,7 +165,25 @@ public class AiController {
boolean enableRag = !isKbDenied(scope); boolean enableRag = !isKbDenied(scope);
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds); List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
return new ChatContext(message, chatId, "CHAT", sys, scope.hasRole() ? scope.allowedMcpTools() : null, cats, return new ChatContext(message, chatId, "CHAT", sys, scope.hasRole() ? scope.allowedMcpTools() : null, cats,
normalizeStrategy(rewriteStrategy), enableRag, false, context.roleId(), scope.name(), context.accountId(), null);
normalizeStrategy(rewriteStrategy), enableRag, false, context.roleId(), scope.name(), context.accountId(), null, null);
}
/** 解析图片 URL 列表(逗号分隔,URL 编码处理) */
private List<String> parseImageUrls(String imageUrls) {
if (!StringUtils.hasText(imageUrls)) {
return Collections.emptyList();
}
return Arrays.stream(imageUrls.split(","))
.map(String::trim)
.filter(StringUtils::hasText)
.map(url -> {
try {
return URLDecoder.decode(url, StandardCharsets.UTF_8);
} catch (Exception e) {
return url;
}
})
.toList();
} }
// ==================== SDK 会话管理接口带账户归属校验 ==================== // ==================== SDK 会话管理接口带账户归属校验 ====================
@ -321,7 +342,8 @@ public class AiController {
@RequestParam(required = false) Boolean enableRag, @RequestParam(required = false) Boolean enableRag,
@RequestParam(required = false) String rewriteStrategy, @RequestParam(required = false) String rewriteStrategy,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String categoryIds) {
@RequestParam(required = false) String categoryIds,
@RequestParam(required = false) String imageUrls) {
ChatContext ctx; ChatContext ctx;
if (Boolean.TRUE.equals(enableRag)) { if (Boolean.TRUE.equals(enableRag)) {
ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId, ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId,
@ -329,6 +351,7 @@ public class AiController {
} else { } else {
ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt); ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt);
} }
ctx = ctx.withImageUrls(parseImageUrls(imageUrls));
return assistantApp.chat(ctx); return assistantApp.chat(ctx);
} }
@ -346,7 +369,8 @@ public class AiController {
@RequestParam(required = false) Boolean enableRag, @RequestParam(required = false) Boolean enableRag,
@RequestParam(required = false) String rewriteStrategy, @RequestParam(required = false) String rewriteStrategy,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String categoryIds) {
@RequestParam(required = false) String categoryIds,
@RequestParam(required = false) String imageUrls) {
ChatContext ctx; ChatContext ctx;
if (Boolean.TRUE.equals(enableRag)) { if (Boolean.TRUE.equals(enableRag)) {
ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId, ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId,
@ -354,9 +378,7 @@ public class AiController {
} else { } else {
ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt); ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt);
} }
ctx = new ChatContext(ctx.message(), ctx.chatId(), ctx.appType(), ctx.systemPrompt(),
ctx.allowedMcpTools(), ctx.categoryIds(), ctx.rewriteStrategy(), ctx.enableRag(), true,
ctx.roleId(), ctx.roleName(), ctx.accountId(), ctx.apiKeyId());
ctx = ctx.withImageUrls(parseImageUrls(imageUrls)).withStreaming(true);
return assistantApp.chatStreamOpenAi(ctx); return assistantApp.chatStreamOpenAi(ctx);
} }

99
src/main/java/com/wok/supportbot/controller/AttachmentController.java

@ -0,0 +1,99 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.config.StorageProperties;
import com.wok.supportbot.service.SftpStorageService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 附件上传控制器
* <p>
* 图片/附件上传到 SFTP返回可公开访问的 URL供对话FAQ markdown 内联展示
* 独立于知识库文档上传{@code DocumentController}只落盘返回链接不做分块/向量化
*/
@RestController
@RequestMapping("/attachment")
public class AttachmentController {
private final SftpStorageService sftpStorageService;
private final StorageProperties storageProperties;
public AttachmentController(SftpStorageService sftpStorageService, StorageProperties storageProperties) {
this.sftpStorageService = sftpStorageService;
this.storageProperties = storageProperties;
}
/**
* 上传图片/附件
*
* @param file 上传的文件
* @return 上传结果data 内含 url / name / type / mimeType / size
*/
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
try {
validateFile(file);
SftpStorageService.SftpUploadResult result = sftpStorageService.upload(file);
String type = isImage(file.getOriginalFilename()) ? "image" : "file";
Map<String, Object> data = new LinkedHashMap<>();
data.put("url", result.accessUrl());
data.put("name", file.getOriginalFilename());
data.put("type", type);
data.put("mimeType", file.getContentType());
data.put("size", result.size());
return ResponseEntity.ok(Map.of(
"success", true,
"message", "上传成功",
"data", data));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "上传失败:" + e.getMessage()));
}
}
/** 校验文件:非空、文件名防路径遍历、大小与类型白名单 */
private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("文件为空");
}
String filename = file.getOriginalFilename();
// 防路径遍历
if (filename != null && (filename.contains("..") || filename.contains("/") || filename.contains("\\"))) {
throw new IllegalArgumentException("文件名非法");
}
if (file.getSize() > storageProperties.getUpload().getMaxSize().toBytes()) {
throw new IllegalArgumentException("文件大小超过限制");
}
String ext = getExtension(filename);
List<String> imageExts = storageProperties.getUpload().getImageExtensions();
List<String> fileExts = storageProperties.getUpload().getFileExtensions();
if (!imageExts.contains(ext) && !fileExts.contains(ext)) {
throw new IllegalArgumentException("不支持的文件类型: " + ext);
}
}
/** 是否图片类型 */
private boolean isImage(String filename) {
return storageProperties.getUpload().getImageExtensions().contains(getExtension(filename));
}
/** 获取扩展名(不含点号,小写) */
private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return null;
}
return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
}
}

2
src/main/java/com/wok/supportbot/controller/OpenApiController.java

@ -225,7 +225,7 @@ public class OpenApiController {
return new ChatContext(message, chatId, "CHAT", systemPrompt, return new ChatContext(message, chatId, "CHAT", systemPrompt,
scope.hasRole() ? scope.allowedMcpTools() : null, scope.hasRole() ? scope.allowedMcpTools() : null,
catIds, strategy, useRag, streaming, roleId, scope.name(), null, apiKey.getId());
catIds, strategy, useRag, streaming, roleId, scope.name(), null, apiKey.getId(), null);
} }
/** /**

143
src/main/java/com/wok/supportbot/service/SftpStorageService.java

@ -0,0 +1,143 @@
package com.wok.supportbot.service;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpException;
import com.wok.supportbot.config.StorageProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.Properties;
import java.util.UUID;
/**
* SFTP 附件存储服务
* <p>
* 照搬参考项目 SftpStorageStrategy 的核心流程
* JSch 建连 逐级创建目录 UUID 命名 {@code channel.put} 上传 拼接 accessUrl 释放连接
*/
@Service
@Slf4j
public class SftpStorageService {
private final StorageProperties storageProperties;
public SftpStorageService(StorageProperties storageProperties) {
this.storageProperties = storageProperties;
}
/**
* 上传文件到 SFTP返回访问链接
*
* @param file 上传的文件
* @return 上传结果含对外访问 URL
*/
public SftpUploadResult upload(MultipartFile file) {
StorageProperties.Sftp sftp = storageProperties.getSftp();
String originalFilename = file.getOriginalFilename();
String extension = getExtension(originalFilename);
String fileKey = UUID.randomUUID().toString().replace("-", "") + extension;
// 目录{biz-id}/{yyyy}/{MM}/{dd}
LocalDate now = LocalDate.now();
String directory = String.join("/",
sftp.getBizId(),
String.valueOf(now.getYear()),
String.valueOf(now.getMonthValue()),
String.valueOf(now.getDayOfMonth()));
Session session = null;
ChannelSftp channel = null;
try (InputStream inputStream = file.getInputStream()) {
JSch jsch = new JSch();
session = jsch.getSession(sftp.getUsername(), sftp.getHost(), sftp.getPort());
session.setPassword(sftp.getPassword());
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.connect((int) sftp.getConnectTimeout().toMillis());
channel = (ChannelSftp) session.openChannel("sftp");
channel.connect((int) sftp.getChannelTimeout().toMillis());
String remotePath = buildRemotePath(sftp.getBasePath(), directory);
createDirectories(channel, remotePath);
String remoteFilePath = remotePath + "/" + fileKey;
channel.put(inputStream, remoteFilePath);
String accessUrl = buildAccessUrl(sftp, remoteFilePath);
log.info("SFTP 上传成功: {} -> {}", originalFilename, accessUrl);
return new SftpUploadResult(fileKey, accessUrl, remoteFilePath, file.getSize());
} catch (Exception e) {
log.error("SFTP 上传失败: filename={}, error={}", originalFilename, e.getMessage(), e);
throw new IllegalStateException("附件上传失败:" + e.getMessage(), e);
} finally {
// 必须释放 channel / session避免连接泄漏
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
if (session != null && session.isConnected()) {
session.disconnect();
}
}
}
/** 远程路径 = 根目录 + 业务目录 */
private String buildRemotePath(String basePath, String directory) {
StringBuilder path = new StringBuilder(basePath);
if (StringUtils.hasText(directory)) {
path.append("/").append(directory);
}
return path.toString();
}
/** 逐级创建目录:先 cd,抛 SftpException 说明不存在,再 mkdir + cd */
private void createDirectories(ChannelSftp channel, String path) throws SftpException {
String[] directories = path.split("/");
StringBuilder currentPath = new StringBuilder();
for (String dir : directories) {
if (!StringUtils.hasText(dir)) {
continue;
}
currentPath.append("/").append(dir);
try {
channel.cd(currentPath.toString());
} catch (SftpException e) {
channel.mkdir(currentPath.toString());
channel.cd(currentPath.toString());
}
}
}
/** 访问链接 = 对外域名前缀 + 远程路径;域名缺失时回退 sftp:// 形式 */
private String buildAccessUrl(StorageProperties.Sftp sftp, String remoteFilePath) {
if (StringUtils.hasText(sftp.getDomain())) {
return sftp.getDomain() + remoteFilePath;
}
return "sftp://" + sftp.getHost() + ":" + sftp.getPort() + remoteFilePath;
}
/** 获取文件扩展名(含点号,小写) */
private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return "";
}
return filename.substring(filename.lastIndexOf(".")).toLowerCase();
}
/**
* SFTP 上传结果
*
* @param fileKey 远端文件名UUID + 扩展名
* @param accessUrl 对外访问链接
* @param remotePath 远端完整路径不含 domain
* @param size 文件大小字节
*/
public record SftpUploadResult(String fileKey, String accessUrl, String remotePath, long size) {
}
}

12
src/main/resources/application-dev.yml

@ -28,6 +28,18 @@ knowledge:
storage: storage:
local-path: D:/uploads local-path: D:/uploads
# ==================== 附件存储(SFTP)配置 ====================
storage:
sftp:
host: 125.88.163.22
port: 8802
username: myuser
password: myuser123456
base-path: /uploads
domain: https://docfilepath.dgscdw.com:8804/ftpfile
connect-timeout: 10s
channel-timeout: 15s
# ==================== 日志级别 ==================== # ==================== 日志级别 ====================
logging: logging:
level: level:

12
src/main/resources/application-prod.yml

@ -37,6 +37,18 @@ knowledge:
storage: storage:
local-path: /www/uploads local-path: /www/uploads
# ==================== 附件存储(SFTP)配置 ====================
storage:
sftp:
host: 192.168.1.67
port: 8802
username: myuser
password: myuser123456
base-path: /uploads
domain: https://docfilepath.dgscdw.com:8804/ftpfile
connect-timeout: 10s
channel-timeout: 15s
# ==================== 日志级别 ==================== # ==================== 日志级别 ====================
# 生产环境收敛日志,仅保留 WARN 及以上,业务代码保留 INFO # 生产环境收敛日志,仅保留 WARN 及以上,业务代码保留 INFO
logging: logging:

12
src/main/resources/application.yml

@ -78,6 +78,18 @@ knowledge:
# 各环境可在 application-{env}.yml 中覆盖 # 各环境可在 application-{env}.yml 中覆盖
local-path: ./uploads local-path: ./uploads
# ==================== 附件上传校验配置(共享) ====================
# 图片/附件白名单与大小限制,所有环境一致。
# SFTP 连接配置(storage.sftp.*)按环境拆分到 application-{env}.yml。
storage:
upload:
# 图片白名单(逗号分隔)
image-extensions: jpg,jpeg,png,gif,webp,bmp
# 附件白名单(逗号分隔)
file-extensions: pdf,zip,doc,docx,xls,xlsx,txt,csv
# 文件大小上限
max-size: 50MB
# ==================== Knife4j API 文档通用配置 ==================== # ==================== Knife4j API 文档通用配置 ====================
# knife4j.enable 开关在各环境 yml 中配置(生产环境建议关闭) # knife4j.enable 开关在各环境 yml 中配置(生产环境建议关闭)
springdoc: springdoc:

38
src/main/resources/static/sdk/test.html

@ -1,21 +1,21 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatbotSDK 测试面板</title>
<script type="module" crossorigin src="/assets/sdk-test-DSxJA5s7.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-rhtNERDv.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-DVS5zdio.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-CeGDR2d-.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-DYDVv77n.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-iiOpcNx_.js">
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatbotSDK 测试面板</title>
<script type="module" crossorigin src="/assets/sdk-test-DwaiWnIv.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-Cd9KbbY5.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-Bw6jx4vm.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-UsDEYppZ.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-ssL4nTk2.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-Bu6ciAhh.js">
<link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css"> <link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css"> <link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-nsiAALas.css">
</head>
<body>
<div id="sdk-test-app"></div>
</body>
</html>
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-wRid8JYa.css">
</head>
<body>
<div id="sdk-test-app"></div>
</body>
</html>

98
知识库文档管理模块验证报告.md

@ -1,98 +0,0 @@
# 知识库文档管理模块验证报告
**测试时间**: 2026-07-02 15:46
**测试环境**: 开发环境 (localhost:9090)
**测试账号**: admin/admin123
---
## 测试结果
| 序号 | 测试用例 | 测试接口 | 预期结果 | 实际结果 | 状态 |
|:---:|---|---|---|---|:---:|
| 1 | 获取文档列表(正常分页) | GET /document/list | 返回分页数据,包含49条文档 | 成功返回49条文档,分页参数正确 | ✅ |
| 2 | 获取文档统计信息 | GET /document/stats | 返回文档统计信息 | 成功返回:总文档49,总向量1218,各类型分布正确 | ✅ |
| 3 | 获取文档详情 | GET /document/{id} | 返回指定文档详情 | 成功返回文档完整信息 | ✅ |
| 4 | 获取文档分块 | GET /document/{id}/chunks | 返回文档的所有分块 | 成功返回分块列表,包含内容和ID | ✅ |
| 5 | 上传文本内容 | POST /upload/string | 创建新文档,状态为PROCESSING | 成功创建文档ID:2072587072527671297,状态PROCESSING | ✅ |
| 6 | 更新文档元信息 | PUT /document/{id} | 更新文档标题等信息 | 成功更新标题从"验证报告"到"已更新" | ✅ |
| 7 | 切换文档启用/禁用状态 | PUT /document/{id}/toggle | 切换enabled字段 | 成功切换,enabled从true变为false,返回"文档已禁用" | ✅ |
| 8 | 重新处理文档 | PUT /document/{id}/reprocess | 提交重新处理任务 | 成功提交,状态变为PROCESSING | ✅ |
| 9 | 删除单个文档 | DELETE /document/{id} | 逻辑删除文档+删除向量 | 成功删除,deletedVectors:1 | ✅ |
| 10 | 批量删除文档 | POST /document/batch/delete | 批量删除指定文档 | 成功删除1个文档,deletedVectors:1 | ✅ |
| 11 | 批量启用/禁用 | POST /document/batch/toggle | 批量切换状态 | 成功启用1个文档,失败0个 | ✅ |
| 12 | 批量移动分类 | POST /document/batch/move | 移动文档到目标分类 | 成功移动1个文档到客服资料分类 | ✅ |
| 13 | 按关键词搜索 | GET /document/list?keyword=盘点 | 返回匹配关键词的文档 | 成功返回1条包含"盘点"的文档 | ✅ |
| 14 | 按状态过滤 | GET /document/list?status=READY | 返回指定状态的文档 | 成功返回49条READY状态文档 | ✅ |
| 15 | 按分类ID过滤 | GET /document/list?categoryId=xxx | 返回指定分类的文档 | 成功返回17条客服资料分类文档 | ✅ |
| 16 | 获取分类树 | GET /category/tree | 返回分类层级结构 | 成功返回3个分类:客服资料、财务资料、古诗词 | ✅ |
| 17 | 获取标签列表 | GET /tag/list | 返回所有标签及使用次数 | 成功返回2个标签:合同(1次)、流程(1次) | ✅ |
| 18 | 获取不存在的文档 | GET /document/99999999999999 | 返回404错误 | 成功返回{"success":false,"message":"文档不存在"} | ✅ |
| 19 | 批量删除空列表 | POST /document/batch/delete | 返回400错误 | 成功返回{"success":false,"message":"请提供要删除的文档ID列表"} | ✅ |
| 20 | 批量切换缺少参数 | POST /document/batch/toggle | 返回400错误 | 成功返回{"success":false,"message":"请指定目标状态"} | ✅ |
| 21 | 更新分块内容 | PUT /document/{id}/chunk/{i} | 更新分块并重新向量化 | ❌ 返回401未登录错误 | ❌ |
| 22 | 删除分块 | DELETE /document/{id}/chunk/{i} | 删除指定分块 | ❌ 返回401未登录错误 | ❌ |
---
## 发现的问题
### 问题1:分块编辑接口认证失败 (严重)
- **问题描述**: PUT /document/{id}/chunk/{chunkIndex} 和 DELETE /document/{id}/chunk/{chunkIndex} 接口返回401未认证错误
- **影响范围**: 无法通过API编辑或删除单个分块
- **可能原因**:
1. Controller路径映射可能与其他路径冲突
2. SecurityConfig可能未正确匹配该路径模式
3. Spring Security的路径匹配规则可能有问题
- **建议修复**:
1. 检查Controller的@RequestMapping配置
2. 验证SecurityFilterChain中的路径匹配规则
3. 考虑使用更明确的路径前缀避免冲突
### 问题2:中文内容编码问题 (轻微)
- **问题描述**: 上传文本内容时,返回的content字段显示为乱码(如"����һ�ݶ˵��˲����ĵ�")
- **影响范围**: 不影响功能,但影响日志可读性
- **可能原因**: curl命令发送请求时的编码问题,非服务端问题
- **建议**: 前端上传时确保UTF-8编码即可,服务端实际存储正确
---
## 测试统计
- **总测试用例**: 22个
- **通过**: 20个 ✅
- **失败**: 2个 ❌
- **通过率**: 90.9%
---
## 总结
### 功能完整性评估
知识库文档管理模块的核心功能基本完整,包括:
- ✅ 文档CRUD操作(上传、查询、更新、删除)
- ✅ 分页查询和多条件过滤
- ✅ 批量操作(删除、启用/禁用、移动分类)
- ✅ 文档状态管理(PROCESSING/READY/FAILED)
- ✅ 分类管理(树形结构)
- ✅ 标签管理
- ✅ 统计面板
- ✅ 错误处理和参数校验
### 主要问题
1. **分块编辑功能异常**:单个分块的更新和删除接口返回401认证错误,无法使用
2. 这是一个**严重问题**,因为分块编辑是知识库管理的重要功能
### 建议
1. **优先修复**分块编辑接口的认证问题
2. 完善接口文档,明确各参数的格式要求
3. 考虑添加更详细的错误信息,便于问题排查
---
**结论**: ❌ **不通过** - 核心功能可用,但分块编辑功能存在认证问题,需要修复后才能通过验证。
---
**验证人**: 自动化测试脚本
**生成时间**: 2026-07-02 15:46
Loading…
Cancel
Save