diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 7b08ef6..aa25820 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -24,7 +24,7 @@ from app.schemas.chat import ( MessageResponse, ) from app.services.audit_service import write_audit -from app.services.document_service import resolve_chat_doc_scope +from app.services.document_service import build_unit_chain, resolve_chat_doc_scope from app.services.user_service import JOB_TITLE_GROUP_ALIASES @@ -178,7 +178,16 @@ def stream_chat( is_admin = current_user.is_superuser or any( role.code in {"admin", "system_admin"} for role in current_user.roles ) - allowed_tags = [] if current_user.is_superuser else [role.code for role in current_user.roles] + # ADP 检索层过滤:角色码 + 提问人 unit_chain(整条向上链,支持就近优先/上级回退) + 公开范围标签. + # 只传角色码时,其他单位的专属文档也会被 ADP 一并召回喂给 LLM,LLM 会在正文写"依据文档:《XX 单位规定》"; + # 把整条链一起下发,是为了让本单位无对应制度时,上级单位的制度也在候选内,由 LLM 按"就近优先"选用. + # 兄弟单位(不在向上链条上的)始终不进 allowed_tags,不会被召回.超管不过滤. + if current_user.is_superuser: + allowed_tags: list[str] = [] + else: + allowed_tags = [role.code for role in current_user.roles] + allowed_tags.extend(build_unit_chain(current_user)) + allowed_tags.extend(["所有人可见", "通用", "全体员工"]) # role_hint 必须是业务岗位(如"总院中层副职""员工"),用来匹配制度里按职级分组的条款。 # 只取 User.job_title;权限角色(employee/admin/system_admin)和 is_superuser 不参与, @@ -286,6 +295,21 @@ def stream_chat( f"【提问人所属单位:{own_unit}】" f"当前单位链上均无对应制度,仅可引用公开制度作答,若信息不足请明确说明。" ) + # 硬约束:兜底 ADP 检索层过滤失效 -> 强制 LLM 拒用 unit_chain 之外单位的专属文档. + # 允许 unit_chain 内的上级单位文档(就近优先由上面 fell_back/chosen_unit 分支引导). + # 优先级最高,凌驾于 FAQ 命中等所有规则.命中 FAQ 但 FAQ 内容显然只覆盖链外单位时,也必须拒答. + hint_lines.append( + f"【单位隔离硬约束·最高优先级】提问人所属单位:{own_unit}。" + f"允许引用的文档必须满足以下之一:" + f"(a) 标签值等于{own_unit};" + f"(b) 标签值为{own_unit}的上级单位(逐级向上);" + f"(c) 标签值为'通用/全体员工/所有人可见'等公开范围。" + f"不满足则:" + f"(1) 禁止将其内容用于作答,禁止在'依据文档'中列出;" + f"(2) 若召回文档全部不满足(即本单位及其上级链上均无对应制度),必须回复原文:" + f"'知识库中未找到 {own_unit} 及其上级单位对应的规定,建议咨询本单位财务部门。';" + f"(3) 此规则覆盖'FAQ 命中即返回标准答案'的短路,即使命中 FAQ 也须先判定单位适用性。" + ) if hint_lines: adp_question = ( f"{raw_question}\n\n" diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index f92c2c5..4f65aa7 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -835,6 +835,33 @@ button { white-space: nowrap; } +/* 共享文档 (ref.source_url 存在) 时, 标题变成蓝色外链, 悬停加下划线 */ +.bubble-reference-item summary .ref-title-link { + color: #1a73e8; + text-decoration: none; + cursor: pointer; +} +.bubble-reference-item summary .ref-title-link:hover { + text-decoration: underline; +} + +/* 展开区域内的链接地址行, 显示完整 URL, 蓝色字, 可换行 */ +.bubble-reference-item .ref-url-line { + display: block; + margin: 6px 0; + padding: 4px 8px; + font-size: 12px; + color: #1a73e8; + background: rgba(26, 115, 232, 0.06); + border-radius: 4px; + word-break: break-all; + text-decoration: none; +} +.bubble-reference-item .ref-url-line:hover { + background: rgba(26, 115, 232, 0.16); + text-decoration: underline; +} + .bubble-reference-item summary .ref-page { color: var(--muted); margin-left: 2px; diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index adea5f5..4d3612f 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -308,10 +308,26 @@ watch(messages, scrollToEnd, { deep: true }) > 📄 - {{ rIdx + 1 }}.{{ ref.source_title || '未知来源' }} + {{ rIdx + 1 }}.{{ ref.source_title || '未知来源' }} + {{ rIdx + 1 }}.{{ ref.source_title || '未知来源' }} 第 {{ ref.page_no }} 页 + 🔗 {{ ref.source_url }}
{{ ref.snippet.trim() }}
diff --git a/miniapp/pages/chat/chat.js b/miniapp/pages/chat/chat.js index ef2783b..e19bb6b 100644 --- a/miniapp/pages/chat/chat.js +++ b/miniapp/pages/chat/chat.js @@ -1,4 +1,5 @@ const { request, uploadFile, streamRequest } = require('../../utils/request') +const { splitMarkdownImages } = require('../../utils/parse-image') const app = getApp() @@ -88,7 +89,11 @@ Page({ const list = this.data.messages.slice() const i = list.findIndex((m) => m.key === key) if (i === -1) return - list[i] = Object.assign({}, list[i], patch) + const merged = Object.assign({}, list[i], patch) + if (patch && typeof patch.text === 'string') { + merged.contentSegments = splitMarkdownImages(merged.text) + } + list[i] = merged this.setData({ messages: list, scrollAnchor: 'anchor-end' }) }, @@ -96,7 +101,11 @@ Page({ const list = this.data.messages.slice() const i = list.findIndex((m) => m.key === key) if (i === -1) return - list[i] = Object.assign({}, list[i], { text: (list[i].text || '') + delta }) + const newText = (list[i].text || '') + delta + list[i] = Object.assign({}, list[i], { + text: newText, + contentSegments: splitMarkdownImages(newText) + }) this.setData({ messages: list, scrollAnchor: 'anchor-end' }) }, @@ -188,10 +197,12 @@ Page({ } if (m.answer || (m.references && m.references.length)) { this._msgSeq += 1 + const ansText = m.answer || '' msgs.push({ key: 'k' + this._msgSeq, role: 'assistant', - text: m.answer || '', + text: ansText, + contentSegments: splitMarkdownImages(ansText), references: m.references || [], streaming: false }) @@ -256,6 +267,115 @@ Page({ } }, + // 参考文档带 source_url 时(共享文档)点击复制链接到剪贴板。 + // 小程序无法直接打开任意外链, 复制后由用户粘贴到浏览器。 + onRefLinkTap(e) { + const url = e.currentTarget.dataset.url + if (!url) return + wx.setClipboardData({ + data: url, + success: () => wx.showToast({ title: '链接已复制', icon: 'success' }), + fail: () => wx.showToast({ title: '复制失败', icon: 'none' }) + }) + }, + + // 长按聊天记录里的图片 (自己发的 or AI 回答里的 markdown 图片) -> 弹菜单 + onImageLongPress(e) { + const src = e.currentTarget.dataset.src + if (!src) return + wx.showActionSheet({ + itemList: ['预览大图', '引用此图追问', '保存到相册'], + success: (r) => { + if (r.tapIndex === 0) wx.previewImage({ current: src, urls: [src] }) + else if (r.tapIndex === 1) this._quoteImage(src) + else if (r.tapIndex === 2) this._saveImageToAlbum(src) + } + }) + }, + + async _quoteImage(src) { + if (this.data.streaming) { + wx.showToast({ title: '当前对话生成中', icon: 'none' }) + return + } + if (this.data.pendingImages.length >= 4) { + wx.showToast({ title: '最多 4 张图', icon: 'none' }) + return + } + const localPath = await this._materializeToLocal(src) + if (!localPath) return + await this._uploadTempImage({ tempFilePath: localPath }) + }, + + async _saveImageToAlbum(src) { + const path = await this._materializeToLocal(src) + if (!path) return + wx.saveImageToPhotosAlbum({ + filePath: path, + success: () => wx.showToast({ title: '已保存到相册' }), + fail: (err) => wx.showToast({ title: '保存失败:' + (err.errMsg || ''), icon: 'none' }) + }) + }, + + // 把任意来源 (远端 URL / data URL / 本地临时路径) 归一化成本地文件路径, + // 后续能直接喂给 wx.uploadFile / wx.saveImageToPhotosAlbum。失败时返回 null 并已给用户提示。 + _materializeToLocal(src) { + if (!src) return Promise.resolve(null) + if (src.indexOf('data:') === 0) return this._dataUrlToTempFile(src) + if (/^https?:\/\//.test(src)) return this._downloadRemote(src) + return Promise.resolve(src) + }, + + _dataUrlToTempFile(dataUrl) { + return new Promise((resolve) => { + const m = /^data:([^;]+);base64,(.+)$/.exec(dataUrl) + if (!m) { + wx.showToast({ title: '图片格式无法识别', icon: 'none' }) + resolve(null) + return + } + const ext = (m[1].split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '') || 'png' + const path = `${wx.env.USER_DATA_PATH}/quote_${Date.now()}.${ext}` + wx.getFileSystemManager().writeFile({ + filePath: path, + data: m[2], + encoding: 'base64', + success: () => resolve(path), + fail: (err) => { + wx.showToast({ title: '写临时文件失败: ' + (err.errMsg || ''), icon: 'none' }) + resolve(null) + } + }) + }) + }, + + _downloadRemote(url) { + wx.showLoading({ title: '拉取图片...', mask: true }) + return new Promise((resolve) => { + wx.downloadFile({ + url, + success: (r) => { + wx.hideLoading() + if (r.statusCode === 200) { + resolve(r.tempFilePath) + } else { + wx.showToast({ title: '下载失败 HTTP ' + r.statusCode, icon: 'none' }) + resolve(null) + } + }, + fail: (err) => { + wx.hideLoading() + wx.showModal({ + title: '拉图失败', + content: (err.errMsg || '') + '\n\n若提示"域名不在合法列表",请在微信公众平台 -> 开发 -> 服务器域名 里把该图片来源域名加进 downloadFile 合法域名。', + showCancel: false + }) + resolve(null) + } + }) + }) + }, + // ---------------- 语音 ---------------- toggleVoiceMode() { this.setData({ voiceMode: !this.data.voiceMode }) diff --git a/miniapp/pages/chat/chat.wxml b/miniapp/pages/chat/chat.wxml index 5b17fac..8d44654 100644 --- a/miniapp/pages/chat/chat.wxml +++ b/miniapp/pages/chat/chat.wxml @@ -43,20 +43,47 @@ src="{{att.data_url}}" mode="aspectFill" bindtap="previewImage" + bindlongpress="onImageLongPress" data-src="{{att.data_url}}" /> - {{item.text}} + {{item.text}} - {{item.text || (item.streaming ? '思考中...' : '')}} + + 思考中... + + + + {{seg.text}} + + + 参考来源 - [{{idx + 1}}] {{ref.source_title}} - {{ref.snippet}} + [{{idx + 1}}] {{ref.source_title}} + + 🔗 + {{ref.source_url}} + + {{ref.snippet}} diff --git a/miniapp/pages/chat/chat.wxss b/miniapp/pages/chat/chat.wxss index f159d87..bbc6dea 100644 --- a/miniapp/pages/chat/chat.wxss +++ b/miniapp/pages/chat/chat.wxss @@ -186,6 +186,16 @@ background: rgba(0, 0, 0, 0.2); } +/* AI 回答里嵌入的 markdown 图片, 宽度撑满气泡, 高度按图片原比例 */ +.ai-img { + display: block; + width: 100%; + max-width: 520rpx; + margin: 12rpx 0; + border-radius: 12rpx; + background: rgba(0, 0, 0, 0.05); +} + .refs { margin-top: 20rpx; padding-top: 16rpx; @@ -206,6 +216,30 @@ color: var(--brand); font-weight: 500; } +.ref-link { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 6rpx; + align-self: stretch; + font-size: 22rpx; + margin-top: 4rpx; + padding: 4rpx 10rpx; + border-radius: 6rpx; + background: rgba(26, 115, 232, 0.06); +} +.ref-link-icon { + color: #1a73e8; + flex-shrink: 0; +} +.ref-link-url { + color: #1a73e8; + word-break: break-all; + line-height: 1.4; +} +.ref-link-hover { + background: rgba(26, 115, 232, 0.16); +} .ref-snippet { font-size: 22rpx; color: #666; diff --git a/miniapp/utils/parse-image.js b/miniapp/utils/parse-image.js new file mode 100644 index 0000000..5876298 --- /dev/null +++ b/miniapp/utils/parse-image.js @@ -0,0 +1,27 @@ +// 把 AI 回答里的 markdown 图片语法 ![alt](url) 从纯文本里切出来, +// 让 chat.wxml 能把图片用真正的 组件渲染 (可长按引用), +// 其余文本仍走 保持现状。 +// 只识别标准 markdown image, 不解析加粗/列表等其他语法。 +const IMG_RE = /!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g + +function splitMarkdownImages(text) { + const src = String(text || '') + if (!src) return [] + const out = [] + let cursor = 0 + IMG_RE.lastIndex = 0 + let m + while ((m = IMG_RE.exec(src)) !== null) { + if (m.index > cursor) { + out.push({ type: 'text', text: src.slice(cursor, m.index) }) + } + out.push({ type: 'img', src: m[1] }) + cursor = IMG_RE.lastIndex + } + if (cursor < src.length) { + out.push({ type: 'text', text: src.slice(cursor) }) + } + return out +} + +module.exports = { splitMarkdownImages }