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 图片语法  从纯文本里切出来,
+// 让 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 }