diff --git a/CLAUDE.md b/CLAUDE.md
index f5f67b6..2d95ee1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -55,9 +55,20 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- `application.yml` 含 API Key,已被 `.gitignore` 排除
- MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法)
-- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象,非数组)
+- **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失
+- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`)
- 向量维度: 1536,距离类型: COSINE_DISTANCE,索引: HNSW
+## 前端架构
+
+- **技术栈**: Vue 3 CDN + ES Module(`importmap` 引入,无构建工具)
+- **入口**: `src/main/resources/static/index.html` → `js/app.js`
+- **组件化**: 每个功能模块一个 JS 文件(`components/` 目录),导出 Vue 组件定义对象
+- **状态管理**: `js/store.js` 使用 Vue 3 `reactive`,跨组件共享分类、统计、弹窗状态
+- **API 封装**: `js/api.js` 统一封装所有后端调用,API 基址为空字符串(同源部署)
+- **SSE 流式**: `js/utils.js` 中 `readSSEStream()` 统一处理三种 SSE 接口
+- **添加新功能**: 在 `components/` 下新建 JS 组件文件,在 `app.js` 中导入注册即可
+
## API 路由约定
- AI 对话: `/ai/*`(`AiController`)
diff --git a/README.md b/README.md
index 05893a4..ad23773 100644
--- a/README.md
+++ b/README.md
@@ -213,7 +213,10 @@ src/main/resources/
├── support-bot.sql # 数据库初始化脚本
├── knowledge-base.sql # 知识库增量迁移脚本
└── static/
- └── frontend.html # 前端管理页面
+ ├── index.html # 前端入口页面
+ ├── css/main.css # 全局样式
+ ├── js/ # Vue 应用、API、状态、工具
+ └── components/ # Vue 组件
```
## 🧩 文档处理管道
@@ -234,7 +237,7 @@ src/main/resources/
## 🖥️ 前端管理页面
-访问 `http://localhost:9090/frontend.html`,包含三个标签页:
+访问 `http://localhost:9090/index.html`,包含三个标签页:
| 标签页 | 功能 |
|--------|------|
@@ -242,6 +245,28 @@ src/main/resources/
| 🏷️ 商品信息提取 | AI 结构化提取商品信息 |
| 📄 知识库文档管理 | 文档上传(6种格式)、分类管理、文档列表、语义搜索测试、统计面板、文档详情查看 |
+前端采用 **Vue 3 CDN + ES Module** 组件化架构,无需构建工具,文件结构如下:
+
+```
+src/main/resources/static/
+├── index.html # 入口页面
+├── css/main.css # 全局样式
+├── js/
+│ ├── app.js # Vue 应用入口
+│ ├── api.js # 统一 API 请求层
+│ ├── store.js # 共享响应式状态
+│ └── utils.js # 工具函数 + SSE 流式读取
+└── components/
+ ├── ChatPanel.js # 对话面板
+ ├── ProductPanel.js # 商品信息提取
+ ├── DocStats.js # 统计面板
+ ├── DocSearch.js # 语义搜索
+ ├── CategoryManager.js # 分类管理
+ ├── DocList.js # 文档列表 + 分页
+ ├── DocUpload.js # 文档上传
+ └── DocDetail.js # 文档详情弹窗
+```
+
## ⚡ 快速开始
### 1. 环境准备
@@ -288,7 +313,7 @@ spring:
### 5. 访问
-- 前端管理页面:http://localhost:9090/frontend.html
+- 前端管理页面:http://localhost:9090/index.html
- API 文档:http://localhost:9090/doc.html
## 📋 查询优化策略
diff --git a/chat.html b/chat.html
deleted file mode 100644
index 8163662..0000000
--- a/chat.html
+++ /dev/null
@@ -1,178 +0,0 @@
-
-
-
-
-
- AI 智能客服 - 对话窗口
-
-
-
-
-
-
-
-
-
🤖
-
- 您好!我是电商智能客服助手,可以帮您解答关于商品、订单、支付、物流和售后等问题。
请问有什么可以帮您的?
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend.html b/frontend.html
deleted file mode 100644
index 221f444..0000000
--- a/frontend.html
+++ /dev/null
@@ -1,1098 +0,0 @@
-
-
-
-
-
-AI 智能客服系统 - Support Bot
-
-
-
-
-
🤖 Support BotAI 智能客服系统
-
- 📖 API 文档
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
💬 智能客服对话
-
基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 💡 当前已启用 RAG 知识库检索,将从向量数据库中检索相关信息以增强回答质量。
-
-
-
-
-
🤖
-
您好!我是电商智能客服助手。
可以帮您解答商品、订单、支付、物流和售后问题。
💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。
📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- GET
- /ai/product_info_app/chat/sync
-
-
🏷️ 商品信息结构化提取
-
输入商品描述文本,AI 自动提取:标题、描述、价格、评分、评论数、品牌、分类
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
📋 文档列表
-
-
-
-
-
-
-
-
-
- | ID |
- 标题 |
- 类型 |
- 状态 |
- 分块数 |
- 创建时间 |
- 操作 |
-
-
-
- | 点击刷新加载文档 |
-
-
-
-
-
-
-
-
-
📤 文档上传
-
上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
POST/upload/file(Tika 多格式解析)
-
-
📎
点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)
-
-
-
-
-
-
-
-
-
-
POST/upload/string(直接上传文本)
-
-
-
-
-
-
-
-
-
POST/upload/markdown
-
-
📑
点击或拖拽上传,支持多文件(Markdown .md)
-
-
-
-
-
-
-
-
-
-
POST/upload/json/basic(整体解析)
-
-
📋
点击或拖拽上传,支持多文件(JSON .json)
-
-
-
-
-
-
-
-
-
-
POST/upload/json/fields(按字段名提取)
-
-
🔑
点击或拖拽上传,支持多文件(JSON .json)
-
-
-
-
-
-
-
-
-
-
-
POST/upload/json/pointer(JSON Pointer 路径拆分)
-
-
📍
点击或拖拽上传,支持多文件(JSON .json)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/main/resources/static/chat.html b/src/main/resources/static/chat.html
deleted file mode 100644
index 8163662..0000000
--- a/src/main/resources/static/chat.html
+++ /dev/null
@@ -1,178 +0,0 @@
-
-
-
-
-
- AI 智能客服 - 对话窗口
-
-
-
-
-
-
-
-
-
🤖
-
- 您好!我是电商智能客服助手,可以帮您解答关于商品、订单、支付、物流和售后等问题。
请问有什么可以帮您的?
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/main/resources/static/components/CategoryManager.js b/src/main/resources/static/components/CategoryManager.js
new file mode 100644
index 0000000..1f1f5ab
--- /dev/null
+++ b/src/main/resources/static/components/CategoryManager.js
@@ -0,0 +1,76 @@
+/**
+ * 🏷️ 分类管理
+ */
+import { ref } from 'vue'
+import { store } from '../js/store.js'
+import { createCategory, deleteCategory } from '../js/api.js'
+import { toast } from '../js/utils.js'
+
+export default {
+ template: `
+
+
🏷️ 分类管理
+
+
+
+
+
+
+
+
+
暂无分类
+
+
+ {{ c.name }} {{ c.description || '' }}
+
+
+
+
+ `,
+ setup() {
+ const name = ref('')
+ const description = ref('')
+ const sortOrder = ref(0)
+
+ async function create() {
+ if (!name.value.trim()) {
+ toast('请输入分类名称', 'error')
+ return
+ }
+ try {
+ const json = await createCategory({
+ name: name.value,
+ description: description.value,
+ sortOrder: sortOrder.value
+ })
+ if (json.success) {
+ toast('分类创建成功', 'success')
+ name.value = ''
+ description.value = ''
+ store.loadCategories()
+ } else {
+ toast(json.message || '创建失败', 'error')
+ }
+ } catch (e) {
+ toast('创建分类失败:' + e.message, 'error')
+ }
+ }
+
+ async function remove(id) {
+ if (!confirm('确定删除此分类?关联文档将变为未分类')) return
+ try {
+ const json = await deleteCategory(id)
+ if (json.success) {
+ toast('分类已删除', 'success')
+ store.loadCategories()
+ } else {
+ toast(json.message || '删除失败', 'error')
+ }
+ } catch (e) {
+ toast('删除分类失败:' + e.message, 'error')
+ }
+ }
+
+ return { name, description, sortOrder, store, create, remove }
+ }
+}
diff --git a/src/main/resources/static/components/ChatPanel.js b/src/main/resources/static/components/ChatPanel.js
new file mode 100644
index 0000000..cbc39fb
--- /dev/null
+++ b/src/main/resources/static/components/ChatPanel.js
@@ -0,0 +1,140 @@
+/**
+ * 💬 智能客服对话面板
+ */
+import { ref, nextTick } from 'vue'
+import { chatSync, chatRagSync, chatSSEUrl } from '../js/api.js'
+import { toast, readSSEStream } from '../js/utils.js'
+
+export default {
+ template: `
+
+
💬 智能客服对话
+
基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 💡 当前已启用 RAG 知识库检索(策略:{{ ragStrategyNames[ragStrategy] || ragStrategy }})
+
+
+
+
+
{{ m.role === 'user' ? '👤' : '🤖' }}
+
{{ m.content }}
+
+
+
+
+
+
+
+
+ `,
+ setup() {
+ const chatId = ref('')
+ const mode = ref('sync')
+ const isRagMode = ref(false)
+ const ragStrategy = ref('REWRITE')
+ const userInput = ref('')
+ const isSending = ref(false)
+ const messages = ref([
+ { role: 'assistant', content: '您好!我是电商智能客服助手。\n可以帮您解答商品、订单、支付、物流和售后问题。\n\n💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。\n📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。', streaming: false }
+ ])
+ const msgArea = ref(null)
+
+ const ragStrategyNames = {
+ NONE: '无重写', REWRITE: '查询重写', TRANSLATION: '翻译扩展',
+ COMPRESSION: '查询压缩', MULTI_QUERY: '多路扩展'
+ }
+
+ function newChatId() {
+ chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6)
+ }
+
+ function clearMessages() {
+ messages.value = [{ role: 'assistant', content: '已清屏,开始新的对话吧~', streaming: false }]
+ }
+
+ async function send() {
+ const text = userInput.value.trim()
+ if (!text || isSending.value) return
+ userInput.value = ''
+ isSending.value = true
+
+ // 添加用户消息
+ messages.value.push({ role: 'user', content: text, streaming: false })
+
+ // 添加助手占位
+ const assistantMsg = { role: 'assistant', content: '', streaming: true }
+ messages.value.push(assistantMsg)
+
+ const cid = chatId.value || ('web_' + Date.now())
+ chatId.value = cid
+
+ try {
+ if (isRagMode.value && mode.value === 'sync') {
+ // RAG 同步
+ const result = await chatRagSync(text, cid, ragStrategy.value)
+ assistantMsg.content = result
+ } else if (isRagMode.value && mode.value !== 'sync') {
+ assistantMsg.content = '⚠️ RAG 模式仅支持同步调用'
+ toast('RAG 模式暂不支持流式调用', 'error')
+ } else if (mode.value === 'sync') {
+ // 普通同步
+ const result = await chatSync(text, cid)
+ assistantMsg.content = result
+ } else {
+ // SSE 流式
+ const url = chatSSEUrl(text, cid, mode.value)
+ await readSSEStream(url,
+ (chunk) => { assistantMsg.content += chunk },
+ () => {}
+ )
+ }
+ } catch (e) {
+ assistantMsg.content = '请求失败:' + e.message
+ toast('对话失败:' + e.message, 'error')
+ } finally {
+ assistantMsg.streaming = false
+ isSending.value = false
+ // 滚动到底部
+ nextTick(() => {
+ if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight
+ })
+ }
+ }
+
+ // 初始化会话 ID
+ newChatId()
+
+ return {
+ chatId, mode, isRagMode, ragStrategy, ragStrategyNames,
+ userInput, isSending, messages, msgArea,
+ newChatId, clearMessages, send
+ }
+ }
+}
diff --git a/src/main/resources/static/components/DocDetail.js b/src/main/resources/static/components/DocDetail.js
new file mode 100644
index 0000000..09189d3
--- /dev/null
+++ b/src/main/resources/static/components/DocDetail.js
@@ -0,0 +1,61 @@
+/**
+ * 📄 文档详情弹窗
+ */
+import { computed } from 'vue'
+import { store } from '../js/store.js'
+import { formatBytes, formatDate } from '../js/utils.js'
+
+export default {
+ template: `
+
+
+
+
📄 文档详情
+
+
+
+
+
文档标题
{{ store.detailModal.doc.title }}
+
原始文件名
{{ store.detailModal.doc.sourceName || '-' }}
+
文件类型
{{ store.detailModal.doc.fileType }}
+
文件大小
{{ formatBytes(store.detailModal.doc.fileSize || 0) }}
+
状态
{{ store.detailModal.doc.status }}
+
分块数
{{ store.detailModal.doc.chunkCount }}
+
分类
{{ store.getCategoryName(store.detailModal.doc.categoryId) }}
+
创建时间
{{ formatDate(store.detailModal.doc.createTime) }}
+
+
📄 原文内容
+
{{ store.detailModal.doc.content || '-' }}
+
+ 🧩 分块详情({{ store.detailModal.chunks.length }} 个)
+
+
+
#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}
+
{{ chunk.content || '' }}
+
+
+
+
+ `,
+ setup() {
+ const statusClass = computed(() => {
+ const status = store.detailModal.doc?.status
+ return status === 'READY' ? 'status-ready'
+ : status === 'PROCESSING' ? 'status-processing'
+ : 'status-failed'
+ })
+
+ function getKeywords(chunk) {
+ let meta = chunk.metadata
+ if (typeof meta === 'object' && meta && meta.value) meta = meta.value
+ try {
+ const m = typeof meta === 'string' ? JSON.parse(meta) : meta
+ return m.excerpt_keywords || ''
+ } catch (e) {
+ return ''
+ }
+ }
+
+ return { store, statusClass, getKeywords, formatBytes, formatDate }
+ }
+}
diff --git a/src/main/resources/static/components/DocList.js b/src/main/resources/static/components/DocList.js
new file mode 100644
index 0000000..c42e069
--- /dev/null
+++ b/src/main/resources/static/components/DocList.js
@@ -0,0 +1,145 @@
+/**
+ * 📋 文档列表 + 分页
+ */
+import { ref } from 'vue'
+import { store } from '../js/store.js'
+import { listDocuments, deleteDocument, reprocessDocument } from '../js/api.js'
+import { toast, formatDate } from '../js/utils.js'
+
+export default {
+ template: `
+
+
📋 文档列表
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 标题 |
+ 类型 |
+ 状态 |
+ 分块数 |
+ 创建时间 |
+ 操作 |
+
+
+
+
+ | 暂无文档 |
+
+
+ | {{ d.id }} |
+
+ {{ d.title }}
+ {{ d.sourceName || '' }}
+ |
+ {{ d.fileType }} |
+ {{ d.status }} |
+ {{ d.chunkCount }} |
+ {{ formatDate(d.createTime) }} |
+
+
+
+
+ |
+
+
+
+
+
+
+
+ `,
+ setup() {
+ const documents = ref([])
+ const page = ref(1)
+ const pages = ref(1)
+ const total = ref(0)
+ const filterCategory = ref('')
+ const filterStatus = ref('')
+
+ function statusClass(status) {
+ return status === 'READY' ? 'status-ready'
+ : status === 'PROCESSING' ? 'status-processing'
+ : 'status-failed'
+ }
+
+ async function load(p = 1) {
+ page.value = p
+ try {
+ const json = await listDocuments(p, 10, filterCategory.value || undefined, filterStatus.value || undefined)
+ if (json.success) {
+ documents.value = json.data || []
+ total.value = json.total || 0
+ pages.value = json.pages || 1
+ } else {
+ toast(json.message || '查询失败', 'error')
+ }
+ } catch (e) {
+ toast('加载文档失败:' + e.message, 'error')
+ }
+ }
+
+ function viewDetail(id) {
+ store.openDetail(id)
+ }
+
+ async function remove(id) {
+ if (!confirm('确定删除此文档?关联的向量也将被删除')) return
+ try {
+ const json = await deleteDocument(id)
+ if (json.success) {
+ toast(`已删除,连带删除 ${json.deletedVectors || 0} 个向量`, 'success')
+ load(page.value)
+ store.loadStats()
+ } else {
+ toast(json.message || '删除失败', 'error')
+ }
+ } catch (e) {
+ toast('删除失败:' + e.message, 'error')
+ }
+ }
+
+ async function reprocess(id) {
+ if (!confirm('确定重新处理此文档?将重新分块并向量化')) return
+ try {
+ const json = await reprocessDocument(id)
+ if (json.success) {
+ toast('重新处理成功', 'success')
+ load(page.value)
+ } else {
+ toast(json.message || '重新处理失败', 'error')
+ }
+ } catch (e) {
+ toast('重新处理失败:' + e.message, 'error')
+ }
+ }
+
+ return {
+ documents, page, pages, total, filterCategory, filterStatus,
+ store, statusClass, load, viewDetail, remove, reprocess, formatDate
+ }
+ }
+}
diff --git a/src/main/resources/static/components/DocSearch.js b/src/main/resources/static/components/DocSearch.js
new file mode 100644
index 0000000..1564cb6
--- /dev/null
+++ b/src/main/resources/static/components/DocSearch.js
@@ -0,0 +1,71 @@
+/**
+ * 🔍 语义搜索测试
+ */
+import { ref } from 'vue'
+import { searchDocuments } from '../js/api.js'
+import { toast } from '../js/utils.js'
+
+export default {
+ template: `
+
+
🔍 语义搜索测试
+
输入查询语句,测试知识库检索效果
+
+
+
+
+
+
+
+
⏳ 搜索中...
+
+
未找到相关结果
+
+
搜索失败:{{ errorMsg }}
+
+
+
+
相似度得分: {{ r.score !== null ? (1 - r.score).toFixed(4) : '-' }} | {{ r.title || '无标题' }} | {{ r.sourceName || '无来源' }}
+
{{ r.content || '' }}
+
+
+
+ `,
+ setup() {
+ const query = ref('')
+ const topK = ref(5)
+ const threshold = ref(0.5)
+ const results = ref([])
+ const isSearching = ref(false)
+ const searched = ref(false)
+ const errorMsg = ref('')
+
+ async function search() {
+ if (!query.value.trim()) {
+ toast('请输入查询内容', 'error')
+ return
+ }
+ isSearching.value = true
+ searched.value = false
+ errorMsg.value = ''
+ try {
+ const json = await searchDocuments(query.value, topK.value, threshold.value)
+ if (!json.success) {
+ errorMsg.value = json.message
+ results.value = []
+ } else {
+ results.value = json.data || []
+ }
+ searched.value = true
+ } catch (e) {
+ errorMsg.value = e.message
+ results.value = []
+ searched.value = true
+ } finally {
+ isSearching.value = false
+ }
+ }
+
+ return { query, topK, threshold, results, isSearching, searched, errorMsg, search }
+ }
+}
diff --git a/src/main/resources/static/components/DocStats.js b/src/main/resources/static/components/DocStats.js
new file mode 100644
index 0000000..1030201
--- /dev/null
+++ b/src/main/resources/static/components/DocStats.js
@@ -0,0 +1,39 @@
+/**
+ * 📊 知识库统计面板
+ */
+import { computed } from 'vue'
+import { store } from '../js/store.js'
+import { formatDate } from '../js/utils.js'
+
+export default {
+ template: `
+
+
📊 知识库概览
+
+
+
{{ store.stats?.totalDocuments || 0 }}
+
文档总数
+
+
+
{{ store.stats?.totalVectors || 0 }}
+
向量总数
+
+
+
{{ store.stats?.lastUploadTime ? formatDate(store.stats.lastUploadTime) : '-' }}
+
最近上传
+
+
+
{{ fileTypeDetail }}
+
+ `,
+ setup() {
+ const fileTypeDetail = computed(() => {
+ if (!store.stats?.byFileType) return ''
+ const entries = Object.entries(store.stats.byFileType)
+ if (entries.length === 0) return ''
+ return '文件类型: ' + entries.map(([k, v]) => `${k}: ${v}`).join(', ')
+ })
+
+ return { store, fileTypeDetail, formatDate }
+ }
+}
diff --git a/src/main/resources/static/components/DocUpload.js b/src/main/resources/static/components/DocUpload.js
new file mode 100644
index 0000000..e2d87d4
--- /dev/null
+++ b/src/main/resources/static/components/DocUpload.js
@@ -0,0 +1,264 @@
+/**
+ * 📤 文档上传面板
+ * 支持 6 种上传格式:通用文件、文本、Markdown、JSON(3种模式)
+ */
+import { ref, reactive } from 'vue'
+import { store } from '../js/store.js'
+import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js'
+import { toast, formatBytes } from '../js/utils.js'
+
+export default {
+ template: `
+
+
📤 文档上传
+
上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
POST/upload/file(Tika 多格式解析)
+
+
📎
点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)
+
+
+
+
+
+
+
+
+
+
POST/upload/string(直接上传文本)
+
+
+
+
+
+
+
+
+
POST/upload/markdown
+
+
📑
点击或拖拽上传,支持多文件(Markdown .md)
+
+
+
+
+
+
+
+
+
+
POST/upload/json/basic(整体解析)
+
+
📋
点击或拖拽上传,支持多文件(JSON .json)
+
+
+
+
+
+
+
+
+
+
POST/upload/json/fields(按字段名提取)
+
+
🔑
点击或拖拽上传,支持多文件(JSON .json)
+
+
+
+
+
+
+
+
+
+
+
POST/upload/json/pointer(JSON Pointer 路径拆分)
+
+
📍
点击或拖拽上传,支持多文件(JSON .json)
+
+
+
+
+
+
+
+
+ `,
+ setup() {
+ const activeSubTab = ref('file')
+ const uploadCategory = ref('')
+ const uploadTags = ref('')
+ const stringTitle = ref('')
+ const stringContent = ref('')
+ const jsonFieldsStr = ref('')
+ const jsonPointerStr = ref('')
+
+ const fileData = reactive({
+ file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null
+ })
+
+ const fileInfo = reactive({
+ file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: ''
+ })
+
+ const results = reactive({
+ file: '', string: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: ''
+ })
+
+ const subTabs = [
+ { key: 'file', icon: '📎', label: '通用文件' },
+ { key: 'string', icon: '📝', label: '文本内容' },
+ { key: 'markdown', icon: '📑', label: 'Markdown' },
+ { key: 'jsonBasic', icon: '📋', label: 'JSON 基本' },
+ { key: 'jsonFields', icon: '🔑', label: 'JSON 按字段' },
+ { key: 'jsonPointer', icon: '📍', label: 'JSON 按指针' }
+ ]
+
+ function handleFileSelect(event, type) {
+ const input = event.target
+ if (!input.files || input.files.length === 0) return
+ fileData[type] = Array.from(input.files)
+ const totalSize = fileData[type].reduce((s, f) => s + f.size, 0)
+ const label = fileData[type].length > 1
+ ? `已选择 ${fileData[type].length} 个文件(共 ${formatBytes(totalSize)})`
+ : `已选择:${fileData[type][0].name} (${formatBytes(fileData[type][0].size)})`
+ fileInfo[type] = label
+ }
+
+ function handleDrop(event, type) {
+ event.currentTarget.classList.remove('drag-over')
+ const refMap = { file: 'fileInput', markdown: 'mdInput', jsonBasic: 'jsonBInput', jsonFields: 'jsonFInput', jsonPointer: 'jsonPInput' }
+ // 模拟文件选择
+ const dt = new DataTransfer()
+ for (const f of event.dataTransfer.files) dt.items.add(f)
+ // 通过 handleFileSelect 处理
+ const fakeEvent = { target: { files: dt.files } }
+ handleFileSelect(fakeEvent, type)
+ }
+
+ async function doUpload(type) {
+ const catId = uploadCategory.value
+ const tagsStr = uploadTags.value.trim()
+
+ // 文本内容上传
+ if (type === 'string') {
+ if (!stringContent.value.trim()) {
+ toast('请输入文本内容', 'error')
+ return
+ }
+ try {
+ const title = stringTitle.value.trim() || stringContent.value.trim().substring(0, 30)
+ const json = await uploadString(stringContent.value, title, catId || undefined, tagsStr || undefined)
+ if (json.success) {
+ results.string = `${json.message} | 分块数:${json.data.chunkCount} | 状态:${json.data.status}
`
+ toast(json.message, 'success')
+ store.loadCategories()
+ store.loadStats()
+ } else {
+ results.string = `${json.message}
`
+ }
+ } catch (e) {
+ results.string = `上传失败:${e.message}
`
+ toast('上传失败:' + e.message, 'error')
+ }
+ return
+ }
+
+ // 文件类上传
+ const files = fileData[type]
+ if (!files || files.length === 0) {
+ toast('请先选择文件', 'error')
+ return
+ }
+
+ let successCount = 0, failCount = 0
+ const resultsHtml = []
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i]
+ try {
+ const formData = new FormData()
+ formData.append('file', file)
+ if (catId) formData.append('categoryId', catId)
+ if (tagsStr) formData.append('tags', tagsStr)
+
+ let json
+ switch (type) {
+ case 'file':
+ json = await uploadFile(formData)
+ break
+ case 'markdown':
+ json = await uploadMarkdown(formData)
+ break
+ case 'jsonBasic':
+ json = await uploadJsonBasic(formData)
+ break
+ case 'jsonFields': {
+ if (!jsonFieldsStr.value.trim()) {
+ toast('请输入要提取的字段名', 'error')
+ return
+ }
+ json = await uploadJsonFields(formData, jsonFieldsStr.value.trim())
+ break
+ }
+ case 'jsonPointer': {
+ if (!jsonPointerStr.value.trim()) {
+ toast('请输入 JSON Pointer 路径', 'error')
+ return
+ }
+ json = await uploadJsonPointer(formData, jsonPointerStr.value.trim())
+ break
+ }
+ }
+
+ if (json.success) {
+ successCount++
+ resultsHtml.push(`${file.name} — ${json.data.chunkCount || 0} 分块`)
+ } else {
+ failCount++
+ resultsHtml.push(`${file.name} — ${json.message || '错误'}`)
+ }
+ } catch (e) {
+ failCount++
+ resultsHtml.push(`${file.name} — ${e.message}`)
+ }
+ }
+
+ const summary = successCount > 0
+ ? `上传完成:成功 ${successCount} 个${failCount > 0 ? `,失败 ${failCount} 个` : ''}`
+ : `全部失败(${failCount} 个文件)`
+ results[type] = `${summary}
${resultsHtml.join('
')}
`
+ toast(summary, successCount > 0 ? 'success' : 'error')
+
+ if (successCount > 0) {
+ store.loadCategories()
+ store.loadStats()
+ }
+ }
+
+ return {
+ activeSubTab, uploadCategory, uploadTags, subTabs,
+ stringTitle, stringContent, jsonFieldsStr, jsonPointerStr,
+ fileData, fileInfo, results, store,
+ handleFileSelect, handleDrop, doUpload, formatBytes
+ }
+ }
+}
diff --git a/src/main/resources/static/components/ProductPanel.js b/src/main/resources/static/components/ProductPanel.js
new file mode 100644
index 0000000..358549b
--- /dev/null
+++ b/src/main/resources/static/components/ProductPanel.js
@@ -0,0 +1,80 @@
+/**
+ * 🏷️ 商品信息结构化提取面板
+ */
+import { ref } from 'vue'
+import { extractProduct } from '../js/api.js'
+import { toast } from '../js/utils.js'
+
+export default {
+ template: `
+
+
+ GET
+ /ai/product_info_app/chat/sync
+
+
🏷️ 商品信息结构化提取
+
输入商品描述文本,AI 自动提取:标题、描述、价格、评分、评论数、品牌、分类
+
+
+
+
+
+
+
+
+
+
+
📊 提取结果
+
+
+
{{ f.label }}
+
{{ result[f.key] !== null && result[f.key] !== undefined ? result[f.key] : '—' }}
+
+
+
{{ jsonText }}
+
+
+ `,
+ setup() {
+ const content = ref('')
+ const result = ref(null)
+ const jsonText = ref('')
+ const isExtracting = ref(false)
+
+ const fields = [
+ { key: 'title', label: '商品标题' },
+ { key: 'description', label: '描述' },
+ { key: 'price', label: '价格' },
+ { key: 'rating', label: '评分' },
+ { key: 'reviewCount', label: '评论数' },
+ { key: 'brand', label: '品牌' },
+ { key: 'category', label: '分类' }
+ ]
+
+ function fillExample() {
+ content.value = 'Apple iPhone 15 Pro Max 256GB 原色钛金属,售价 ¥9999,京东好评率98%,累计2.5万+评价,品牌Apple,属于智能手机分类'
+ }
+
+ async function extract() {
+ if (!content.value.trim()) {
+ toast('请输入商品描述内容', 'error')
+ return
+ }
+ isExtracting.value = true
+ try {
+ const text = await extractProduct(content.value)
+ const data = JSON.parse(text)
+ result.value = data
+ jsonText.value = JSON.stringify(data, null, 2)
+ toast('商品信息提取成功!', 'success')
+ } catch (e) {
+ toast('提取失败:' + e.message, 'error')
+ } finally {
+ isExtracting.value = false
+ }
+ }
+
+ return { content, result, jsonText, isExtracting, fields, extract, fillExample }
+ }
+}
diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css
new file mode 100644
index 0000000..d4b3783
--- /dev/null
+++ b/src/main/resources/static/css/main.css
@@ -0,0 +1,136 @@
+/* ==================== CSS 变量 ==================== */
+:root { --bg:#f0f2f5; --card:#fff; --primary:#6366f1; --primary2:#8b5cf6; --success:#10b981; --warn:#f59e0b; --danger:#ef4444; --text:#1f2937; --sub:#6b7280; --border:#e5e7eb; --radius:10px; }
+
+* { margin:0; padding:0; box-sizing:border-box; }
+body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
+
+/* ==================== 顶部导航 ==================== */
+.topbar { background:linear-gradient(135deg, var(--primary) 0%, var(--primary2) 100%); color:#fff; padding:0 24px; height:56px; display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:100; }
+.topbar .logo { font-size:20px; font-weight:700; }
+.topbar .ver { font-size:12px; opacity:.7; margin-left:4px; }
+.topbar .links { margin-left:auto; display:flex; gap:12px; }
+.topbar .links a { color:#fff; opacity:.8; text-decoration:none; font-size:13px; transition:opacity .2s; }
+.topbar .links a:hover { opacity:1; }
+
+/* ==================== Tab 导航 ==================== */
+.tabs { display:flex; gap:0; background:var(--card); border-bottom:1px solid var(--border); padding:0 24px; position:sticky; top:56px; z-index:99; }
+.tab-btn { padding:14px 24px; border:none; background:none; font-size:14px; cursor:pointer; border-bottom:3px solid transparent; color:var(--sub); transition:all .2s; font-family:inherit; display:flex; align-items:center; gap:6px; }
+.tab-btn:hover { color:var(--text); }
+.tab-btn.active { color:var(--primary); border-bottom-color:var(--primary); font-weight:600; }
+.tab-icon { font-size:18px; }
+
+/* ==================== 内容区 ==================== */
+.main { max-width:1400px; margin:0 auto; padding:20px; }
+@keyframes fadeIn { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} }
+
+/* ==================== 卡片 ==================== */
+.card { background:var(--card); border-radius:var(--radius); padding:20px; margin-bottom:16px; border:1px solid var(--border); }
+.card h2 { font-size:16px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
+.card h3 { font-size:14px; margin-bottom:8px; color:var(--sub); }
+
+/* ==================== 表单 ==================== */
+.input-row { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
+.input, .textarea, .select { padding:10px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; }
+.input:focus, .textarea:focus, .select:focus { border-color:var(--primary); }
+.input { flex:1; min-width:200px; }
+.textarea { width:100%; resize:vertical; min-height:120px; }
+.select { min-width:160px; }
+.input-sm { width:120px; flex:none; }
+
+.btn { padding:10px 20px; border:none; border-radius:8px; font-size:14px; cursor:pointer; font-family:inherit; font-weight:500; transition:all .2s; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
+.btn:disabled { opacity:.5; cursor:not-allowed; }
+.btn-primary { background:var(--primary); color:#fff; }
+.btn-primary:hover:not(:disabled) { background:#4f46e5; }
+.btn-purple { background:var(--primary2); color:#fff; }
+.btn-purple:hover:not(:disabled) { background:#7c3aed; }
+.btn-success { background:var(--success); color:#fff; }
+.btn-outline { background:#fff; color:var(--primary); border:1px solid var(--primary); }
+.btn-sm { padding:6px 12px; font-size:12px; }
+.btn-danger { background:var(--danger); color:#fff; }
+.btn-warn { background:var(--warn); color:#fff; }
+
+/* ==================== 消息区 ==================== */
+.msg-area { border:1px solid var(--border); border-radius:var(--radius); height:400px; overflow-y:auto; padding:16px; background:#fafbfc; margin-bottom:12px; display:flex; flex-direction:column; gap:10px; }
+.msg { display:flex; gap:10px; max-width:85%; animation: fadeIn .3s; }
+.msg.user { align-self:flex-end; flex-direction:row-reverse; }
+.msg.assistant { align-self:flex-start; }
+.msg-avatar { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:16px; flex-shrink:0; }
+.msg.user .msg-avatar { background:var(--primary); color:#fff; }
+.msg.assistant .msg-avatar { background:var(--success); color:#fff; }
+.msg-bubble { padding:10px 14px; border-radius:12px; line-height:1.6; font-size:14px; word-break:break-word; white-space:pre-wrap; }
+.msg.user .msg-bubble { background:var(--primary); color:#fff; border-bottom-right-radius:4px; }
+.msg.assistant .msg-bubble { background:#fff; border:1px solid var(--border); border-bottom-left-radius:4px; }
+.msg.streaming .msg-bubble { border-color:var(--primary); box-shadow:0 0 0 1px var(--primary); }
+
+/* ==================== 商品信息展示 ==================== */
+.result-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:12px; margin-top:12px; }
+.result-item { background:#f9fafb; padding:12px 16px; border-radius:8px; border:1px solid var(--border); }
+.result-item .label { font-size:12px; color:var(--sub); margin-bottom:4px; }
+.result-item .value { font-size:14px; font-weight:500; }
+.result-json { background:#1e293b; color:#e2e8f0; padding:16px; border-radius:8px; font-family:'Fira Code',monospace; font-size:13px; white-space:pre-wrap; overflow-x:auto; }
+
+/* ==================== 文件上传区 ==================== */
+.upload-zone { border:2px dashed var(--border); border-radius:var(--radius); padding:32px; text-align:center; transition:all .2s; cursor:pointer; margin-bottom:12px; }
+.upload-zone:hover, .upload-zone.drag-over { border-color:var(--primary); background:#eef2ff; }
+.upload-zone p { color:var(--sub); }
+.upload-zone .icon { font-size:40px; margin-bottom:8px; }
+
+/* ==================== Toast ==================== */
+.toast-container { position:fixed; top:70px; right:20px; z-index:9999; display:flex; flex-direction:column; gap:8px; }
+.toast { padding:10px 18px; border-radius:8px; color:#fff; font-size:13px; animation:slideIn .3s; box-shadow:0 4px 12px rgba(0,0,0,.15); }
+.toast.success { background:var(--success); }
+.toast.error { background:var(--danger); }
+.toast.info { background:var(--primary); }
+@keyframes slideIn { from{opacity:0;transform:translateX(100px);} to{opacity:1;transform:translateX(0);} }
+
+/* ==================== 流式结果比较 ==================== */
+.stream-compare { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
+.stream-compare .card { margin-bottom:0; }
+
+/* ==================== 表格样式 ==================== */
+.data-table { width:100%; border-collapse:collapse; font-size:13px; }
+.data-table th { background:#f9fafb; padding:10px 12px; text-align:left; font-weight:600; border-bottom:2px solid var(--border); white-space:nowrap; }
+.data-table td { padding:10px 12px; border-bottom:1px solid var(--border); vertical-align:middle; }
+.data-table tr:hover { background:#f9fafb; }
+.data-table .status-ready { color:var(--success); font-weight:600; }
+.data-table .status-processing { color:var(--warn); font-weight:600; }
+.data-table .status-failed { color:var(--danger); font-weight:600; }
+
+/* ==================== 分页 ==================== */
+.pagination { display:flex; gap:4px; justify-content:center; margin-top:12px; }
+.pagination button { padding:6px 12px; border:1px solid var(--border); background:#fff; border-radius:6px; cursor:pointer; font-size:12px; }
+.pagination button:hover:not(:disabled) { background:var(--primary); color:#fff; border-color:var(--primary); }
+.pagination button:disabled { opacity:.5; cursor:not-allowed; }
+.pagination button.active { background:var(--primary); color:#fff; border-color:var(--primary); }
+
+/* ==================== 统计卡片 ==================== */
+.stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:16px; }
+.stat-card { background:#f9fafb; border-radius:8px; padding:16px; text-align:center; border:1px solid var(--border); }
+.stat-card .number { font-size:28px; font-weight:700; color:var(--primary); }
+.stat-card .label { font-size:12px; color:var(--sub); margin-top:4px; }
+
+/* ==================== 弹窗 ==================== */
+.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); z-index:200; display:none; align-items:center; justify-content:center; }
+.modal-overlay.active { display:flex; }
+.modal-box { background:#fff; border-radius:var(--radius); width:90%; max-width:800px; max-height:85vh; overflow-y:auto; padding:24px; position:relative; }
+.modal-box h2 { margin-bottom:16px; font-size:18px; }
+.modal-close { position:absolute; top:16px; right:20px; font-size:24px; cursor:pointer; color:var(--sub); background:none; border:none; }
+.modal-close:hover { color:var(--text); }
+
+/* ==================== 分类标签 ==================== */
+.category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#eef2ff; color:var(--primary); }
+
+/* ==================== 搜索结果 ==================== */
+.search-result { background:#f9fafb; border-radius:8px; padding:12px; margin-bottom:8px; border:1px solid var(--border); }
+.search-result .score { font-size:12px; color:var(--success); font-weight:600; }
+.search-result .meta { font-size:12px; color:var(--sub); margin-top:4px; }
+.search-result .content { font-size:13px; margin-top:6px; line-height:1.5; }
+
+/* ==================== 端点标签 ==================== */
+.badge { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; font-weight:600; }
+.badge-get { background:#dbeafe; color:#1d4ed8; }
+.badge-post { background:#fef3c7; color:#b45309; }
+.endpoint-info { display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap; }
+
+/* ==================== 响应式 ==================== */
+@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } .stat-grid { grid-template-columns:1fr 1fr; } }
diff --git a/src/main/resources/static/frontend.html b/src/main/resources/static/frontend.html
deleted file mode 100644
index 221f444..0000000
--- a/src/main/resources/static/frontend.html
+++ /dev/null
@@ -1,1098 +0,0 @@
-
-
-
-
-
-AI 智能客服系统 - Support Bot
-
-
-
-
-
🤖 Support BotAI 智能客服系统
-
- 📖 API 文档
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
💬 智能客服对话
-
基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 💡 当前已启用 RAG 知识库检索,将从向量数据库中检索相关信息以增强回答质量。
-
-
-
-
-
🤖
-
您好!我是电商智能客服助手。
可以帮您解答商品、订单、支付、物流和售后问题。
💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。
📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- GET
- /ai/product_info_app/chat/sync
-
-
🏷️ 商品信息结构化提取
-
输入商品描述文本,AI 自动提取:标题、描述、价格、评分、评论数、品牌、分类
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
📋 文档列表
-
-
-
-
-
-
-
-
-
- | ID |
- 标题 |
- 类型 |
- 状态 |
- 分块数 |
- 创建时间 |
- 操作 |
-
-
-
- | 点击刷新加载文档 |
-
-
-
-
-
-
-
-
-
📤 文档上传
-
上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
POST/upload/file(Tika 多格式解析)
-
-
📎
点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)
-
-
-
-
-
-
-
-
-
-
POST/upload/string(直接上传文本)
-
-
-
-
-
-
-
-
-
POST/upload/markdown
-
-
📑
点击或拖拽上传,支持多文件(Markdown .md)
-
-
-
-
-
-
-
-
-
-
POST/upload/json/basic(整体解析)
-
-
📋
点击或拖拽上传,支持多文件(JSON .json)
-
-
-
-
-
-
-
-
-
-
POST/upload/json/fields(按字段名提取)
-
-
🔑
点击或拖拽上传,支持多文件(JSON .json)
-
-
-
-
-
-
-
-
-
-
-
POST/upload/json/pointer(JSON Pointer 路径拆分)
-
-
📍
点击或拖拽上传,支持多文件(JSON .json)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html
new file mode 100644
index 0000000..acd0513
--- /dev/null
+++ b/src/main/resources/static/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ AI 智能客服系统 - Support Bot
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js
new file mode 100644
index 0000000..3c515be
--- /dev/null
+++ b/src/main/resources/static/js/api.js
@@ -0,0 +1,259 @@
+/**
+ * 统一 API 请求层
+ * 封装所有后端接口调用,组件只需调用函数处理业务逻辑
+ */
+import { API_BASE } from './utils.js'
+
+// ==================== 通用请求 ====================
+
+/**
+ * GET 请求,返回 JSON
+ */
+async function getJSON(path) {
+ const res = await fetch(API_BASE + path)
+ return res.json()
+}
+
+/**
+ * POST JSON 请求,返回 JSON
+ */
+async function postJSON(path, body) {
+ const res = await fetch(API_BASE + path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ })
+ return res.json()
+}
+
+/**
+ * DELETE 请求,返回 JSON
+ */
+async function deleteJSON(path) {
+ const res = await fetch(API_BASE + path, { method: 'DELETE' })
+ return res.json()
+}
+
+/**
+ * PUT 请求,返回 JSON
+ */
+async function putJSON(path, params) {
+ let url = API_BASE + path
+ if (params) {
+ const qs = Object.entries(params)
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
+ .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
+ .join('&')
+ if (qs) url += (url.includes('?') ? '&' : '?') + qs
+ }
+ const res = await fetch(url, { method: 'PUT' })
+ return res.json()
+}
+
+/**
+ * POST FormData 请求,返回 JSON
+ */
+async function postForm(path, formData) {
+ const res = await fetch(API_BASE + path, {
+ method: 'POST',
+ body: formData
+ })
+ return res.json()
+}
+
+// ==================== AI 对话 ====================
+
+/**
+ * 同步对话
+ */
+export function chatSync(message, chatId) {
+ return fetch(API_BASE + `/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`)
+ .then(res => res.text())
+}
+
+/**
+ * RAG 同步对话
+ */
+export function chatRagSync(message, chatId, strategy) {
+ return fetch(API_BASE + `/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`)
+ .then(res => res.text())
+}
+
+/**
+ * 获取 SSE 流式对话 URL
+ * @param {'sse'|'sse2'|'sse3'} mode
+ * @returns {string}
+ */
+export function chatSSEUrl(message, chatId, mode) {
+ const pathMap = {
+ sse: '/ai/assistant_app/chat/sse',
+ sse2: '/ai/assistant_app/chat/server_sent_event',
+ sse3: '/ai/assistant_app/chat/sse_emitter'
+ }
+ return API_BASE + `${pathMap[mode]}?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`
+}
+
+// ==================== 商品信息提取 ====================
+
+/**
+ * 提取商品信息
+ */
+export function extractProduct(content) {
+ return fetch(API_BASE + `/ai/product_info_app/chat/sync?message=${encodeURIComponent(content)}`)
+ .then(res => res.text())
+}
+
+// ==================== 文档管理 ====================
+
+/**
+ * 文档列表(分页 + 过滤)
+ */
+export function listDocuments(page = 1, size = 10, categoryId, status) {
+ let path = `/document/list?page=${page}&size=${size}`
+ if (categoryId) path += `&categoryId=${categoryId}`
+ if (status) path += `&status=${status}`
+ return getJSON(path)
+}
+
+/**
+ * 文档详情
+ */
+export function getDocumentDetail(id) {
+ return getJSON(`/document/${id}`)
+}
+
+/**
+ * 文档分块列表
+ */
+export function getDocumentChunks(id) {
+ return getJSON(`/document/${id}/chunks`)
+}
+
+/**
+ * 删除文档
+ */
+export function deleteDocument(id) {
+ return deleteJSON(`/document/${id}`)
+}
+
+/**
+ * 更新文档元信息
+ */
+export function updateDocument(id, title, categoryId, tags) {
+ return putJSON(`/document/${id}`, { title, categoryId, tags })
+}
+
+/**
+ * 重新处理文档
+ */
+export function reprocessDocument(id) {
+ return putJSON(`/document/${id}/reprocess`)
+}
+
+// ==================== 语义搜索 ====================
+
+/**
+ * 语义搜索
+ */
+export function searchDocuments(query, topK, similarityThreshold, categoryId) {
+ const body = { query, topK, similarityThreshold }
+ if (categoryId) body.categoryId = categoryId
+ return postJSON('/document/search', body)
+}
+
+// ==================== 统计 ====================
+
+/**
+ * 知识库统计
+ */
+export function getStats() {
+ return getJSON('/document/stats')
+}
+
+// ==================== 上传 ====================
+
+/**
+ * 上传普通文件
+ */
+export function uploadFile(formData) {
+ return postForm('/upload/file', formData)
+}
+
+/**
+ * 上传文本内容
+ */
+export function uploadString(content, title, categoryId, tags) {
+ let url = `/upload/string?title=${encodeURIComponent(title)}`
+ if (categoryId) url += `&categoryId=${categoryId}`
+ if (tags) url += `&tags=${encodeURIComponent(tags)}`
+ return fetch(API_BASE + url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/plain' },
+ body: content
+ }).then(res => res.json())
+}
+
+/**
+ * 上传 Markdown
+ */
+export function uploadMarkdown(formData) {
+ return postForm('/upload/markdown', formData)
+}
+
+/**
+ * 上传 JSON(基本方式)
+ */
+export function uploadJsonBasic(formData) {
+ return postForm('/upload/json/basic', formData)
+}
+
+/**
+ * 上传 JSON(按字段提取)
+ */
+export function uploadJsonFields(formData, fields) {
+ return postForm(`/upload/json/fields?fields=${encodeURIComponent(fields)}`, formData)
+}
+
+/**
+ * 上传 JSON(按指针拆分)
+ */
+export function uploadJsonPointer(formData, pointer) {
+ return postForm(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData)
+}
+
+// ==================== 分类 ====================
+
+/**
+ * 分类树
+ */
+export function getCategoryTree() {
+ return getJSON('/category/tree')
+}
+
+/**
+ * 分类列表
+ */
+export function getCategoryList() {
+ return getJSON('/category/list')
+}
+
+/**
+ * 创建分类
+ */
+export function createCategory(data) {
+ return postJSON('/category', data)
+}
+
+/**
+ * 更新分类
+ */
+export function updateCategory(id, data) {
+ return putJSON(`/category/${id}`, data)
+}
+
+/**
+ * 删除分类
+ */
+export function deleteCategory(id) {
+ return deleteJSON(`/category/${id}`)
+}
diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js
new file mode 100644
index 0000000..7c58407
--- /dev/null
+++ b/src/main/resources/static/js/app.js
@@ -0,0 +1,95 @@
+/**
+ * Vue 应用入口
+ * 组合所有组件,管理 Tab 切换和顶层布局
+ */
+import { createApp, ref } from 'vue'
+import { store } from './store.js'
+
+// 导入组件
+import ChatPanel from '../components/ChatPanel.js'
+import ProductPanel from '../components/ProductPanel.js'
+import DocStats from '../components/DocStats.js'
+import DocSearch from '../components/DocSearch.js'
+import CategoryManager from '../components/CategoryManager.js'
+import DocList from '../components/DocList.js'
+import DocUpload from '../components/DocUpload.js'
+import DocDetail from '../components/DocDetail.js'
+
+const app = createApp({
+ setup() {
+ const activeTab = ref('chat')
+
+ // 切换 Tab,进入知识库 Tab 时自动加载数据
+ function switchTab(tab) {
+ activeTab.value = tab
+ if (tab === 'document') {
+ store.loadCategories()
+ store.loadStats()
+ }
+ }
+
+ return { activeTab, switchTab, store }
+ },
+ template: `
+
+
+
🤖 Support BotAI 智能客服系统
+
+ 📖 API 文档
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+})
+
+// 注册组件
+app.component('chat-panel', ChatPanel)
+app.component('product-panel', ProductPanel)
+app.component('doc-stats', DocStats)
+app.component('doc-search', DocSearch)
+app.component('category-manager', CategoryManager)
+app.component('doc-list', DocList)
+app.component('doc-upload', DocUpload)
+app.component('doc-detail', DocDetail)
+
+app.mount('#app')
diff --git a/src/main/resources/static/js/store.js b/src/main/resources/static/js/store.js
new file mode 100644
index 0000000..7af67cb
--- /dev/null
+++ b/src/main/resources/static/js/store.js
@@ -0,0 +1,86 @@
+/**
+ * 共享响应式状态
+ * 跨组件共享的分类数据、统计数据、弹窗状态
+ */
+import { reactive } from 'vue'
+import * as api from './api.js'
+
+export const store = reactive({
+ // ==================== 分类数据 ====================
+ categories: [],
+ categoryMap: {},
+
+ /**
+ * 加载分类列表,同步更新 categoryMap
+ */
+ async loadCategories() {
+ try {
+ const json = await api.getCategoryList()
+ if (json.success) {
+ this.categories = json.data || []
+ this.categoryMap = {}
+ this.categories.forEach(c => { this.categoryMap[c.id] = c.name })
+ }
+ } catch (e) {
+ console.error('加载分类失败', e)
+ }
+ },
+
+ /**
+ * 获取分类名称
+ */
+ getCategoryName(categoryId) {
+ return this.categoryMap[categoryId] || (categoryId && categoryId !== '0' ? '未知' : '未分类')
+ },
+
+ // ==================== 统计数据 ====================
+ stats: null,
+
+ /**
+ * 加载统计数据
+ */
+ async loadStats() {
+ try {
+ const json = await api.getStats()
+ if (json.success) {
+ this.stats = json.data
+ }
+ } catch (e) {
+ console.error('加载统计失败', e)
+ }
+ },
+
+ // ==================== 文档详情弹窗 ====================
+ detailModal: {
+ visible: false,
+ doc: null,
+ chunks: []
+ },
+
+ /**
+ * 打开文档详情弹窗
+ */
+ async openDetail(docId) {
+ try {
+ const [detailRes, chunksRes] = await Promise.all([
+ api.getDocumentDetail(docId),
+ api.getDocumentChunks(docId)
+ ])
+ if (!detailRes.success) return
+ this.detailModal.doc = detailRes.data
+ this.detailModal.chunks = chunksRes.success ? (chunksRes.data || []) : []
+ this.detailModal.visible = true
+ } catch (e) {
+ console.error('加载文档详情失败', e)
+ }
+ },
+
+ /**
+ * 关闭文档详情弹窗
+ */
+ closeDetail() {
+ this.detailModal.visible = false
+ this.detailModal.doc = null
+ this.detailModal.chunks = []
+ }
+})
diff --git a/src/main/resources/static/js/utils.js b/src/main/resources/static/js/utils.js
new file mode 100644
index 0000000..08ab623
--- /dev/null
+++ b/src/main/resources/static/js/utils.js
@@ -0,0 +1,99 @@
+/**
+ * 工具函数模块
+ * 提供 Toast 提示、格式化、SSE 流式读取等通用能力
+ */
+
+// API 基址:空字符串 = 同源部署,由 Spring Boot 直接服务前端
+export const API_BASE = ''
+
+// ==================== Toast 提示 ====================
+
+let _toastContainer = null
+
+/**
+ * 获取或创建 Toast 容器
+ */
+function getToastContainer() {
+ if (!_toastContainer) {
+ _toastContainer = document.getElementById('toasts')
+ }
+ return _toastContainer
+}
+
+/**
+ * 显示 Toast 提示
+ * @param {string} msg 消息内容
+ * @param {'info'|'success'|'error'} type 类型
+ */
+export function toast(msg, type = 'info') {
+ const container = getToastContainer()
+ if (!container) return
+ const el = document.createElement('div')
+ el.className = 'toast ' + type
+ el.textContent = msg
+ container.appendChild(el)
+ setTimeout(() => {
+ el.style.opacity = '0'
+ el.style.transition = 'opacity .3s'
+ setTimeout(() => el.remove(), 300)
+ }, 2500)
+}
+
+// ==================== 格式化工具 ====================
+
+/**
+ * 格式化文件大小
+ * @param {number} b 字节数
+ * @returns {string}
+ */
+export function formatBytes(b) {
+ return b < 1024 ? b + ' B'
+ : b < 1048576 ? (b / 1024).toFixed(1) + ' KB'
+ : (b / 1048576).toFixed(1) + ' MB'
+}
+
+/**
+ * 格式化日期
+ * @param {string|Date} d 日期
+ * @returns {string}
+ */
+export function formatDate(d) {
+ if (!d) return '-'
+ const date = new Date(d)
+ return date.toLocaleString('zh-CN')
+}
+
+// ==================== SSE 流式读取 ====================
+
+/**
+ * 通用 SSE 流式读取
+ * 统一处理 Flux / ServerSentEvent / SseEmitter 三种 SSE 接口
+ *
+ * @param {string} url 请求地址
+ * @param {function(string): void} onChunk 每收到一段文本的回调
+ * @param {function(): void} onDone 流结束的回调
+ */
+export async function readSSEStream(url, onChunk, onDone) {
+ const res = await fetch(url)
+ if (!res.ok) throw new Error('HTTP ' + res.status)
+ const reader = res.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split('\n')
+ buffer = lines.pop() || ''
+ for (const line of lines) {
+ if (line.startsWith('data:')) {
+ const data = line.slice(5).trim()
+ if (data && data !== '[DONE]') onChunk(data)
+ } else if (line.trim() && !line.startsWith(':')) {
+ // Flux 模式,非 SSE 标准格式,直接作为内容
+ onChunk(line)
+ }
+ }
+ }
+ if (onDone) onDone()
+}