Compare commits
1
Commits
e1a4558a49
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee3c0e1084 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# 页面标题
|
# 页面标题
|
||||||
VITE_APP_TITLE = 数智AI
|
VITE_APP_TITLE = SK Agent
|
||||||
|
|
||||||
# 开发环境配置
|
# 开发环境配置
|
||||||
VITE_APP_ENV = 'development'
|
VITE_APP_ENV = 'development'
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# 页面标题
|
# 页面标题
|
||||||
VITE_APP_TITLE = 数智AI
|
VITE_APP_TITLE = SK Agent
|
||||||
|
|
||||||
# 生产环境配置
|
# 生产环境配置
|
||||||
VITE_APP_ENV = 'production'
|
VITE_APP_ENV = 'production'
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# 页面标题
|
# 页面标题
|
||||||
VITE_APP_TITLE = 数智AI
|
VITE_APP_TITLE = SK Agent
|
||||||
|
|
||||||
# 生产环境配置
|
# 生产环境配置
|
||||||
VITE_APP_ENV = 'staging'
|
VITE_APP_ENV = 'staging'
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 838 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 930 KiB |
@@ -5,7 +5,11 @@
|
|||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<template v-for="(group, groupIndex) in groupedMessages" :key="groupIndex">
|
<template v-for="(group, groupIndex) in groupedMessages" :key="groupIndex">
|
||||||
<!-- 用户消息组 -->
|
<!-- 用户消息组 -->
|
||||||
<div v-if="group.user" class="message-group user-group">
|
<div
|
||||||
|
v-if="group.user"
|
||||||
|
class="message-group user-group"
|
||||||
|
:ref="el => setQuestionGroupRef(el, userQuestionIndexAt(groupIndex))"
|
||||||
|
>
|
||||||
<div class="message-bubble user-bubble">
|
<div class="message-bubble user-bubble">
|
||||||
<div class="message-text" v-html="formatContent(group.user.content)"></div>
|
<div class="message-text" v-html="formatContent(group.user.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,7 +36,11 @@
|
|||||||
<ArrowDown />
|
<ArrowDown />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<pre v-if="collapsedGroupThinking[groupIndex] !== true" class="tool-content">{{ getThinkingContent(group.tools) }}</pre>
|
<div
|
||||||
|
v-if="collapsedGroupThinking[groupIndex] !== true"
|
||||||
|
class="tool-content thinking-content"
|
||||||
|
v-html="getThinkingContent(group.tools)"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-for="(tool, toolIndex) in group.tools" :key="'tool-' + toolIndex">
|
<template v-for="(tool, toolIndex) in group.tools" :key="'tool-' + toolIndex">
|
||||||
@@ -71,10 +79,10 @@
|
|||||||
<el-icon v-if="loading && groupIndex === groupedMessages.length - 1" class="stream-loading is-loading"><Loading /></el-icon>
|
<el-icon v-if="loading && groupIndex === groupedMessages.length - 1" class="stream-loading is-loading"><Loading /></el-icon>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="(loading || group.assistant?.isThinking) && groupIndex === groupedMessages.length - 1">
|
<template v-else-if="(loading || group.assistant?.isThinking) && groupIndex === groupedMessages.length - 1">
|
||||||
|
正在思考
|
||||||
<span class="loading-dots">
|
<span class="loading-dots">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</span>
|
</span>
|
||||||
正在思考...
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="group.assistant && group.assistant.content?.trim()" class="message-time">{{ formatTime(group.assistant.timestamp) }}</div>
|
<div v-if="group.assistant && group.assistant.content?.trim()" class="message-time">{{ formatTime(group.assistant.timestamp) }}</div>
|
||||||
@@ -93,11 +101,19 @@
|
|||||||
>
|
>
|
||||||
<el-icon><ArrowDown /></el-icon>
|
<el-icon><ArrowDown /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧问题定位导航:横杆 + 悬停气泡 -->
|
||||||
|
<MessageNavigator
|
||||||
|
:questions="userQuestions"
|
||||||
|
:active-index="activeQuestionIndex"
|
||||||
|
@select="handleNavigatorSelect"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, shallowRef, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, shallowRef, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
import MessageNavigator from './MessageNavigator.vue'
|
||||||
import { Clock, ArrowDown, CircleCheck, CircleClose, ChatDotRound, Loading } from '@element-plus/icons-vue'
|
import { Clock, ArrowDown, CircleCheck, CircleClose, ChatDotRound, Loading } from '@element-plus/icons-vue'
|
||||||
import { createMarkdownRenderer } from '@/views/frontend/utils/highlighter'
|
import { createMarkdownRenderer } from '@/views/frontend/utils/highlighter'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
@@ -205,13 +221,99 @@ function toggleGroup(index) {
|
|||||||
collapsedGroups.value[index] = collapsedGroups.value[index] === false ? true : false
|
collapsedGroups.value[index] = collapsedGroups.value[index] === false ? true : false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 消息定位导航相关 ——
|
||||||
|
// 用户提问列表(去掉 markdown 标记、空白、过长文本,便于气泡展示)
|
||||||
|
const userQuestions = computed(() => {
|
||||||
|
const list = []
|
||||||
|
for (const group of groupedMessages.value) {
|
||||||
|
if (group.user) {
|
||||||
|
const raw = String(group.user.content || '').trim()
|
||||||
|
const cleaned = raw
|
||||||
|
.replace(/```[\s\S]*?```/g, ' ')
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||||
|
.replace(/\[([^\]]+)]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/^#{1,6}\s*/gm, '')
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||||
|
.replace(/\*([^*]+)\*/g, '$1')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
list.push(cleaned || raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
// 给定 groupedMessages 的下标,返回该 group 对应的 userQuestions 索引(仅当属于用户问题时)
|
||||||
|
function userQuestionIndexAt(groupIndex) {
|
||||||
|
let qIdx = -1
|
||||||
|
const groups = groupedMessages.value
|
||||||
|
for (let i = 0; i <= groupIndex && i < groups.length; i++) {
|
||||||
|
if (groups[i].user) qIdx++
|
||||||
|
}
|
||||||
|
return qIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户问题组对应的 DOM 元素
|
||||||
|
const questionGroupRefs = new Map()
|
||||||
|
function setQuestionGroupRef(el, questionIndex) {
|
||||||
|
if (el) questionGroupRefs.set(questionIndex, el)
|
||||||
|
else questionGroupRefs.delete(questionIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeQuestionIndex = ref(-1)
|
||||||
|
|
||||||
|
function scrollToQuestion(index) {
|
||||||
|
const el = questionGroupRefs.get(index)
|
||||||
|
const wrap = getScrollWrap()
|
||||||
|
if (!el || !wrap) return
|
||||||
|
const wrapRect = wrap.getBoundingClientRect()
|
||||||
|
const elRect = el.getBoundingClientRect()
|
||||||
|
const targetTop = wrap.scrollTop + (elRect.top - wrapRect.top) - 12
|
||||||
|
wrap.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' })
|
||||||
|
activeQuestionIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNavigatorSelect(index) {
|
||||||
|
scrollToQuestion(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动时,根据最靠近视口顶部的用户问题组更新 activeIndex
|
||||||
|
function updateActiveQuestionByScroll() {
|
||||||
|
const wrap = getScrollWrap()
|
||||||
|
if (!wrap || userQuestions.value.length === 0) {
|
||||||
|
activeQuestionIndex.value = -1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const wrapRect = wrap.getBoundingClientRect()
|
||||||
|
const probeY = wrapRect.top + 24
|
||||||
|
let best = -1
|
||||||
|
let bestDistance = Infinity
|
||||||
|
for (const [index, el] of questionGroupRefs.entries()) {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
if (rect.bottom < probeY) continue
|
||||||
|
const distance = Math.abs(rect.top - probeY)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
best = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best === -1 && userQuestions.value.length > 0) {
|
||||||
|
best = userQuestions.value.length - 1
|
||||||
|
}
|
||||||
|
activeQuestionIndex.value = best
|
||||||
|
}
|
||||||
|
|
||||||
function getThinkingTools(tools) {
|
function getThinkingTools(tools) {
|
||||||
return tools ? tools.filter(t => t.kind === 'thinking') : []
|
return tools ? tools.filter(t => t.kind === 'thinking') : []
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThinkingContent(tools) {
|
function getThinkingContent(tools) {
|
||||||
const thinkingList = getThinkingTools(tools)
|
const thinkingList = getThinkingTools(tools)
|
||||||
return thinkingList.map(t => formatContent(t.content)).join('\n\n')
|
// 每段思考内容用独立 div 包裹,便于在 HTML 模式下产生视觉分隔
|
||||||
|
return thinkingList
|
||||||
|
.map(t => `<div class="thinking-block">${formatContent(t.content)}</div>`)
|
||||||
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGroupThinking(groupIndex) {
|
function toggleGroupThinking(groupIndex) {
|
||||||
@@ -271,18 +373,14 @@ function formatToolResult(content) {
|
|||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const wrap = scrollRef.value?.$refs?.wrapRef
|
scrollToBottomOf(getScrollWrap())
|
||||||
if (wrap) {
|
|
||||||
wrap.scrollTop = wrap.scrollHeight
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 自动下拉相关状态 ——
|
// —— 自动下拉相关状态 ——
|
||||||
const userScrolledUp = ref(false)
|
const userScrolledUp = ref(false)
|
||||||
const showScrollButton = ref(false)
|
const showScrollButton = ref(false)
|
||||||
let pollTimer = null
|
let mutationObserver = null
|
||||||
let lastPolledScrollHeight = 0
|
|
||||||
|
|
||||||
// 距底阈值:< 50px 视为已触底,> 200px 显示向下箭头
|
// 距底阈值:< 50px 视为已触底,> 200px 显示向下箭头
|
||||||
const BOTTOM_THRESHOLD = 2
|
const BOTTOM_THRESHOLD = 2
|
||||||
@@ -292,51 +390,58 @@ function getScrollWrap() {
|
|||||||
return scrollRef.value?.$refs?.wrapRef
|
return scrollRef.value?.$refs?.wrapRef
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动轮询:每 100ms 检测一次 scrollHeight 变化,有变化就滚到底,直到稳定
|
// 贴底:scrollTop 必须是 scrollHeight - clientHeight(最大合法位置)
|
||||||
function startPolling() {
|
function scrollToBottomOf(wrap) {
|
||||||
if (pollTimer) return
|
if (!wrap) return
|
||||||
// 强制第一次迭代滚动(lastPolledScrollHeight 与当前不同)
|
wrap.scrollTop = wrap.scrollHeight - wrap.clientHeight
|
||||||
lastPolledScrollHeight = -1
|
|
||||||
pollTimer = setInterval(() => {
|
|
||||||
const wrap = getScrollWrap()
|
|
||||||
if (!wrap) {
|
|
||||||
stopPolling()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (wrap.scrollHeight !== lastPolledScrollHeight) {
|
|
||||||
lastPolledScrollHeight = wrap.scrollHeight
|
|
||||||
wrap.scrollTop = wrap.scrollHeight
|
|
||||||
} else if (!props.loading) {
|
|
||||||
// 流式回复期间保持轮询,回复结束且高度稳定后再停止
|
|
||||||
stopPolling()
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling() {
|
// 启动 MutationObserver:监听消息容器内部 DOM 变化(流式追加/字符变化)
|
||||||
if (pollTimer) {
|
// ResizeObserver 只追踪元素自身 box 尺寸,无法感知 scrollable 容器内的内容增长,故不用
|
||||||
clearInterval(pollTimer)
|
function setupAutoScroll() {
|
||||||
pollTimer = null
|
if (mutationObserver) return
|
||||||
|
const wrap = getScrollWrap()
|
||||||
|
if (!wrap) return
|
||||||
|
// 观察 wrapper 内第一个子节点(即 .messages-wrapper),覆盖整棵子树
|
||||||
|
const content = wrap.firstElementChild
|
||||||
|
if (!content) return
|
||||||
|
mutationObserver = new MutationObserver(() => {
|
||||||
|
if (userScrolledUp.value) return
|
||||||
|
// 推迟到下一帧,确保 scrollHeight 已是最新值
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToBottomOf(getScrollWrap())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mutationObserver.observe(content, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardownAutoScroll() {
|
||||||
|
if (mutationObserver) {
|
||||||
|
mutationObserver.disconnect()
|
||||||
|
mutationObserver = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听用户滚动:触底则开启轮询;任何非触底状态都停止轮询(消除灰色地带抖动)
|
// 监听用户滚动:触底则恢复自动滚动;任何非触底状态都进入"用户上拉"模式
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const wrap = getScrollWrap()
|
const wrap = getScrollWrap()
|
||||||
if (!wrap) return
|
if (!wrap) return
|
||||||
const distance = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight
|
const distance = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight
|
||||||
if (distance <= BOTTOM_THRESHOLD) {
|
if (distance <= BOTTOM_THRESHOLD) {
|
||||||
// 已触底:恢复轮询,隐藏箭头
|
// 已触底:恢复自动滚动,隐藏箭头
|
||||||
userScrolledUp.value = false
|
userScrolledUp.value = false
|
||||||
showScrollButton.value = false
|
showScrollButton.value = false
|
||||||
if (pollTimer === null) startPolling()
|
|
||||||
} else {
|
} else {
|
||||||
// 任何非触底状态:停止轮询,避免与用户滚动冲突
|
// 任何非触底状态:标记为上拉,避免与用户滚动冲突
|
||||||
// 仅在距离 > 200px 时显示箭头按钮
|
// 仅在距离 > 200px 时显示箭头按钮
|
||||||
stopPolling()
|
|
||||||
userScrolledUp.value = true
|
userScrolledUp.value = true
|
||||||
showScrollButton.value = distance > SHOW_BUTTON_THRESHOLD
|
showScrollButton.value = distance > SHOW_BUTTON_THRESHOLD
|
||||||
}
|
}
|
||||||
|
updateActiveQuestionByScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 平滑滚动到底部(自定义时长,easeInOutCubic 缓动)
|
// 平滑滚动到底部(自定义时长,easeInOutCubic 缓动)
|
||||||
@@ -372,54 +477,32 @@ function animateScrollToBottom(duration = 500) {
|
|||||||
function forceScrollToBottom() {
|
function forceScrollToBottom() {
|
||||||
userScrolledUp.value = false
|
userScrolledUp.value = false
|
||||||
showScrollButton.value = false
|
showScrollButton.value = false
|
||||||
stopPolling()
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const wrap = getScrollWrap()
|
scrollToBottomOf(getScrollWrap())
|
||||||
if (!wrap) return
|
|
||||||
wrap.scrollTop = wrap.scrollHeight - wrap.clientHeight
|
|
||||||
lastPolledScrollHeight = wrap.scrollHeight
|
|
||||||
startPolling()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击向下箭头:0.5s 动画滚动到底,结束后恢复轮询
|
// 点击向下箭头:0.5s 动画滚动到底
|
||||||
function handleArrowClick() {
|
function handleArrowClick() {
|
||||||
userScrolledUp.value = false
|
userScrolledUp.value = false
|
||||||
showScrollButton.value = false
|
showScrollButton.value = false
|
||||||
stopPolling()
|
animateScrollToBottom(500)
|
||||||
animateScrollToBottom(500).then(() => {
|
|
||||||
if (pollTimer === null) startPolling()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息 push 时:未上拉则开始轮询;上拉中只更新高度记录
|
// 消息 push 时:刷新右侧导航的激活项;自动滚动由 ResizeObserver 接管
|
||||||
watch(() => props.messages.length, () => {
|
watch(() => props.messages.length, () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const wrap = getScrollWrap()
|
updateActiveQuestionByScroll()
|
||||||
if (!wrap) return
|
|
||||||
if (!userScrolledUp.value) {
|
|
||||||
lastPolledScrollHeight = wrap.scrollHeight
|
|
||||||
startPolling()
|
|
||||||
} else {
|
|
||||||
// 用户在上拉阅读,记录最新高度但不滚动
|
|
||||||
lastPolledScrollHeight = wrap.scrollHeight
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// loading 变化(开始/结束流式):未上拉则启动轮询
|
// loading 变化(开始/结束流式):ResizeObserver 在内容尺寸变化时自动贴底,无需额外处理
|
||||||
watch(() => props.loading, (val) => {
|
watch(() => props.loading, () => {
|
||||||
if (!userScrolledUp.value) {
|
// 占位:保留以备后续扩展(如 loading 转换时的视觉过渡)
|
||||||
nextTick(() => {
|
|
||||||
const wrap = getScrollWrap()
|
|
||||||
if (wrap) lastPolledScrollHeight = wrap.scrollHeight
|
|
||||||
startPolling()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// When Shiki finishes loading, `formatContent` will pick up the populated
|
// When Shiki finishes loading, `formatContent` will pick up the populated
|
||||||
@@ -428,14 +511,13 @@ watch(() => props.loading, (val) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const wrap = getScrollWrap()
|
setupAutoScroll()
|
||||||
if (wrap) lastPolledScrollHeight = wrap.scrollHeight
|
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopPolling()
|
teardownAutoScroll()
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({ scrollToBottom, forceScrollToBottom })
|
defineExpose({ scrollToBottom, forceScrollToBottom })
|
||||||
@@ -532,7 +614,7 @@ defineExpose({ scrollToBottom, forceScrollToBottom })
|
|||||||
color: var(--fe-text-primary);
|
color: var(--fe-text-primary);
|
||||||
border-radius: 10px 10px 10px 4px;
|
border-radius: 10px 10px 10px 4px;
|
||||||
box-shadow: var(--fe-shadow-sm);
|
box-shadow: var(--fe-shadow-sm);
|
||||||
max-width: 70%;
|
max-width: 90%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +667,56 @@ defineExpose({ scrollToBottom, forceScrollToBottom })
|
|||||||
:deep(a) {
|
:deep(a) {
|
||||||
color: var(--fe-accent);
|
color: var(--fe-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Markdown 表格:补齐边框、单元格内边距与最小宽度,避免列挤在一起
|
||||||
|
:deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--fe-bg-base);
|
||||||
|
border: 1px solid var(--fe-border);
|
||||||
|
border-radius: var(--fe-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
display: table;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(th), :deep(td) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--fe-border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
// 关闭父级 break-word 带来的字符级断行
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
min-width: 80px;
|
||||||
|
color: var(--fe-text-primary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(th) {
|
||||||
|
background: var(--fe-bg-overlay);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fe-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(tr) {
|
||||||
|
&:nth-child(even) td {
|
||||||
|
background: var(--fe-bg-overlay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格如果仍超出容器宽度,允许横向滚动而不是挤压列
|
||||||
|
:deep(.table-wrapper) {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,6 +814,71 @@ defineExpose({ scrollToBottom, forceScrollToBottom })
|
|||||||
color: var(--fe-text-secondary);
|
color: var(--fe-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thinking-content {
|
||||||
|
// 思考过程按 HTML 渲染,去掉 monospace,使用正常字体
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: normal;
|
||||||
|
|
||||||
|
:deep(.thinking-block) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 思考过程中常见排版样式:让段落、列表与文本与气泡整体一致
|
||||||
|
:deep(p) {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(code) {
|
||||||
|
background: var(--fe-bg-overlay);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: var(--fe-radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(pre) {
|
||||||
|
background: var(--fe-bg-base);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--fe-radius-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid var(--fe-border);
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul), :deep(ol) {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(a) {
|
||||||
|
color: var(--fe-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(em) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.collapse-icon {
|
.collapse-icon {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
<div v-if="!collapsed" class="sidebar-content">
|
<div v-if="!collapsed" class="sidebar-content">
|
||||||
<slot name="content">
|
<slot name="content">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<span>会话列表</span>
|
<div class="marketplace-tabs-header">
|
||||||
|
<img :src="logoSrc" alt="智能体" />
|
||||||
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-tooltip content="新建会话" placement="top" :show-after="300">
|
<el-tooltip content="新建会话" placement="top" :show-after="300">
|
||||||
<el-button class="icon-btn" link @click="handleNewSession">
|
<el-button class="icon-btn" link @click="handleNewSession">
|
||||||
@@ -95,8 +97,13 @@ import { listChatSessions, deleteChatSession, renameChatSession } from '@/api/fr
|
|||||||
import { formatTimeAgo } from '@/utils/time'
|
import { formatTimeAgo } from '@/utils/time'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import UserArea from './UserArea.vue'
|
import UserArea from './UserArea.vue'
|
||||||
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
import logoName from '@/assets/logo/logo_name.png'
|
||||||
|
import logoNameDark from '@/assets/logo/logo_name_dark.png'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const logoSrc = computed(() => settingsStore.frontendTheme === 'dark' ? logoNameDark : logoName)
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
const sessionList = ref([])
|
const sessionList = ref([])
|
||||||
const currentSession = ref(null)
|
const currentSession = ref(null)
|
||||||
@@ -398,14 +405,22 @@ defineExpose({
|
|||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 11px 16px;
|
padding: 5px 16px 5px 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 1px solid var(--fe-border);
|
border-bottom: 1px solid var(--fe-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--fe-text-primary);
|
color: var(--fe-text-primary);
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 180px;
|
||||||
|
height: auto;
|
||||||
|
max-height: 44px;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: left center;
|
||||||
|
}
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<div v-else-if="fileTree.length === 0" class="empty-wrapper">
|
<div v-else-if="fileTree.length === 0" class="empty-wrapper">
|
||||||
<span>暂无文件</span>
|
<span>暂无文件</span>
|
||||||
</div>
|
</div>
|
||||||
<el-scrollbar v-else height="calc(100vh - 200px)">
|
<el-scrollbar v-else height="calc(100vh - 120px)">
|
||||||
<el-tree
|
<el-tree
|
||||||
:data="fileTree"
|
:data="fileTree"
|
||||||
:props="treeProps"
|
:props="treeProps"
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="totalQuestions > 0"
|
||||||
|
class="message-navigator"
|
||||||
|
@mouseenter="onRailEnter"
|
||||||
|
@mouseleave="onRailLeave"
|
||||||
|
>
|
||||||
|
<div ref="navRailRef" class="nav-rail" @scroll="handleRailScroll">
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in displayBars"
|
||||||
|
:key="idx"
|
||||||
|
class="nav-bar-slot"
|
||||||
|
@click="handleBarClick(item)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="nav-bar"
|
||||||
|
:class="{
|
||||||
|
active: item.index === activeIndex,
|
||||||
|
hover: item.index === hoverIndex
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="nav-popup-fade">
|
||||||
|
<div v-show="popupVisible" class="nav-popup" @mouseenter="onPopupEnter" @mouseleave="onPopupLeave">
|
||||||
|
<div ref="popupListRef" class="popup-list" @scroll="handlePopupScroll">
|
||||||
|
<div
|
||||||
|
v-for="(q, qIdx) in questions"
|
||||||
|
:key="qIdx"
|
||||||
|
class="popup-item"
|
||||||
|
:class="{ active: qIdx === activeIndex, hover: qIdx === hoverIndex }"
|
||||||
|
@mouseenter="hoverIndex = qIdx"
|
||||||
|
@mouseleave="hoverIndex = -1"
|
||||||
|
@click="handleSelect(qIdx)"
|
||||||
|
>
|
||||||
|
<span class="popup-item-text" :title="q">{{ q }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
questions: { type: Array, default: () => [] },
|
||||||
|
activeIndex: { type: Number, default: -1 }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
|
||||||
|
const MAX_BARS = 9
|
||||||
|
const ROW_HEIGHT = 32
|
||||||
|
const VISIBLE_ROWS = 9
|
||||||
|
const popupVisible = ref(false)
|
||||||
|
const hoverIndex = ref(-1)
|
||||||
|
let leaveTimer = null
|
||||||
|
// 记录气泡框关闭前的滚动位置,便于重新打开时恢复
|
||||||
|
const savedPopupScrollTop = ref(0)
|
||||||
|
|
||||||
|
const totalQuestions = computed(() => props.questions.length)
|
||||||
|
|
||||||
|
// rail 渲染与列表一一对应的所有横杆。两侧容器共享同一个滚动位置和相同的行高
|
||||||
|
// (32px + 上下 4px 内边距),因此无论列表滚到哪里,对应的横杆都在同一行。
|
||||||
|
const displayBars = computed(() => {
|
||||||
|
return props.questions.map((_, i) => ({ index: i, overflow: false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 同步滚动:rail 与 popup-list 共享同一个滚动位置(每行高度一致)
|
||||||
|
const navRailRef = ref(null)
|
||||||
|
const popupListRef = ref(null)
|
||||||
|
let syncing = false
|
||||||
|
|
||||||
|
function handleRailScroll() {
|
||||||
|
if (syncing) return
|
||||||
|
const rail = navRailRef.value
|
||||||
|
const list = popupListRef.value
|
||||||
|
if (!rail || !list) return
|
||||||
|
syncing = true
|
||||||
|
list.scrollTop = rail.scrollTop
|
||||||
|
requestAnimationFrame(() => { syncing = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePopupScroll() {
|
||||||
|
const list = popupListRef.value
|
||||||
|
if (list) savedPopupScrollTop.value = list.scrollTop
|
||||||
|
if (syncing) return
|
||||||
|
const rail = navRailRef.value
|
||||||
|
if (!rail || !list) return
|
||||||
|
syncing = true
|
||||||
|
rail.scrollTop = list.scrollTop
|
||||||
|
requestAnimationFrame(() => { syncing = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 rail 与 popup-list 同步滚动到指定位置(让当前激活项处于可视区中央)
|
||||||
|
function scrollToActive() {
|
||||||
|
const list = popupListRef.value
|
||||||
|
const rail = navRailRef.value
|
||||||
|
if (!list || !rail) return
|
||||||
|
const idx = props.activeIndex
|
||||||
|
const total = props.questions.length
|
||||||
|
let target
|
||||||
|
if (idx >= 0) {
|
||||||
|
// 让激活项尽量位于 9 行可视窗口的中央
|
||||||
|
target = Math.max(0, (idx - Math.floor(VISIBLE_ROWS / 2)) * ROW_HEIGHT)
|
||||||
|
const maxScroll = Math.max(0, (total - VISIBLE_ROWS) * ROW_HEIGHT)
|
||||||
|
target = Math.min(target, maxScroll)
|
||||||
|
} else {
|
||||||
|
target = savedPopupScrollTop.value || 0
|
||||||
|
}
|
||||||
|
syncing = true
|
||||||
|
list.scrollTop = target
|
||||||
|
rail.scrollTop = target
|
||||||
|
requestAnimationFrame(() => { syncing = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLeaveTimer() {
|
||||||
|
if (leaveTimer) {
|
||||||
|
clearTimeout(leaveTimer)
|
||||||
|
leaveTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRailEnter() {
|
||||||
|
clearLeaveTimer()
|
||||||
|
popupVisible.value = true
|
||||||
|
// 重新打开时:让气泡框滚动到当前激活项的位置,保持与 rail 严格对齐
|
||||||
|
nextTick(() => {
|
||||||
|
requestAnimationFrame(() => scrollToActive())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRailLeave() {
|
||||||
|
clearLeaveTimer()
|
||||||
|
leaveTimer = setTimeout(() => {
|
||||||
|
if (hoverIndex.value === -1) popupVisible.value = false
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPopupEnter() {
|
||||||
|
clearLeaveTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPopupLeave() {
|
||||||
|
clearLeaveTimer()
|
||||||
|
hoverIndex.value = -1
|
||||||
|
leaveTimer = setTimeout(() => {
|
||||||
|
popupVisible.value = false
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBarClick(item) {
|
||||||
|
if (item.overflow) return
|
||||||
|
handleSelect(item.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(index) {
|
||||||
|
emit('select', index)
|
||||||
|
// 点击后立即隐藏气泡
|
||||||
|
hoverIndex.value = -1
|
||||||
|
popupVisible.value = false
|
||||||
|
clearLeaveTimer()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.message-navigator {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 9;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
pointer-events: auto;
|
||||||
|
// 不固定高度:高度由 rail 决定(≤9 条自适应,>9 条被 max-height 截断为 300px)
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0;
|
||||||
|
padding: 4px 4px;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
// 自适应高度:≤9 条时 = 内容高度;>9 条时固定 300px 并内部滚动
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bar-slot {
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
width: 10px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--fe-border-strong);
|
||||||
|
border-radius: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, width 0.15s ease, transform 0.15s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--fe-accent);
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hover:not(.active):not(.overflow) {
|
||||||
|
background: var(--fe-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.overflow {
|
||||||
|
width: 8px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--fe-text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.overflow) {
|
||||||
|
background: var(--fe-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-popup {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 360px;
|
||||||
|
background: var(--fe-bg-elevated);
|
||||||
|
border: 1px solid var(--fe-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--fe-shadow-lg);
|
||||||
|
padding: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
// 上下内边距与 nav-rail 保持一致(4px),确保首行严格对齐
|
||||||
|
padding: 4px 20px 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
// 隐藏滚动条(视觉上不显示,但保留滚动能力)
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--fe-text-primary);
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: right;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.hover,
|
||||||
|
&:hover {
|
||||||
|
background: var(--fe-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--fe-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-item-text {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-popup-fade-enter-active,
|
||||||
|
.nav-popup-fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.nav-popup-fade-enter-from,
|
||||||
|
.nav-popup-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(6px);
|
||||||
|
}
|
||||||
|
.nav-popup-fade-enter-to,
|
||||||
|
.nav-popup-fade-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,18 +7,25 @@
|
|||||||
<button @click="zoomOut" :disabled="scale <= 0.5">缩小</button>
|
<button @click="zoomOut" :disabled="scale <= 0.5">缩小</button>
|
||||||
<button @click="zoomIn" :disabled="scale >= 3">放大</button>
|
<button @click="zoomIn" :disabled="scale >= 3">放大</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pdf-canvas-container" ref="containerRef">
|
<div class="pdf-canvas-container" ref="containerRef" @scroll.passive="handleScroll">
|
||||||
<canvas ref="canvasRef"></canvas>
|
<div
|
||||||
|
v-for="page in totalPages"
|
||||||
|
:key="page"
|
||||||
|
:ref="el => setPageRef(el, page)"
|
||||||
|
class="pdf-page"
|
||||||
|
:data-page="page"
|
||||||
|
>
|
||||||
|
<canvas :ref="el => setCanvasRef(el, page)"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
import PdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url'
|
import PdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url'
|
||||||
|
|
||||||
// 设置 worker
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker
|
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -26,74 +33,122 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const containerRef = ref(null)
|
const containerRef = ref(null)
|
||||||
const canvasRef = ref(null)
|
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const totalPages = ref(0)
|
const totalPages = ref(0)
|
||||||
const scale = ref(1.2)
|
const scale = ref(0.87)
|
||||||
|
// 渲染分辨率倍率:实际按 scale * RENDER_DPR 渲染到 canvas,再缩到 scale 对应显示尺寸。
|
||||||
|
// 提高 DPR 可让低缩放(如 0.87)下文字/图形边缘更清晰。
|
||||||
|
const RENDER_DPR = 2
|
||||||
let pdfDoc = null
|
let pdfDoc = null
|
||||||
let renderTask = null
|
const pageRefs = new Map()
|
||||||
|
const canvasRefs = new Map()
|
||||||
|
let suppressScrollSync = false
|
||||||
|
|
||||||
|
function setPageRef(el, page) {
|
||||||
|
if (el) pageRefs.set(page, el)
|
||||||
|
else pageRefs.delete(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCanvasRef(el, page) {
|
||||||
|
if (el) canvasRefs.set(page, el)
|
||||||
|
else canvasRefs.delete(page)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPdf() {
|
async function loadPdf() {
|
||||||
if (pdfDoc) {
|
if (pdfDoc) {
|
||||||
try { pdfDoc.destroy() } catch (e) {}
|
try { pdfDoc.destroy() } catch (e) {}
|
||||||
pdfDoc = null
|
pdfDoc = null
|
||||||
}
|
}
|
||||||
|
pageRefs.clear()
|
||||||
|
canvasRefs.clear()
|
||||||
const loadingTask = pdfjsLib.getDocument({ data: props.arrayBuffer })
|
const loadingTask = pdfjsLib.getDocument({ data: props.arrayBuffer })
|
||||||
pdfDoc = await loadingTask.promise
|
pdfDoc = await loadingTask.promise
|
||||||
totalPages.value = pdfDoc.numPages
|
totalPages.value = pdfDoc.numPages
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
renderPage()
|
await nextTick()
|
||||||
|
await renderAllPages()
|
||||||
|
scrollToPage(1, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage() {
|
async function renderAllPages() {
|
||||||
if (!pdfDoc || !canvasRef.value) return
|
if (!pdfDoc || !containerRef.value) return
|
||||||
if (renderTask) {
|
const pages = []
|
||||||
renderTask.cancel()
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||||
renderTask = null
|
pages.push(pdfDoc.getPage(i).then(page => ({ index: i, page })))
|
||||||
}
|
}
|
||||||
pdfDoc.getPage(currentPage.value).then(page => {
|
const results = await Promise.all(pages)
|
||||||
const viewport = page.getViewport({ scale: scale.value })
|
for (const { index, page } of results) {
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRefs.get(index)
|
||||||
const context = canvas.getContext('2d')
|
if (!canvas) continue
|
||||||
canvas.style.width = viewport.width + 'px'
|
const renderScale = scale.value * RENDER_DPR
|
||||||
canvas.style.height = viewport.height + 'px'
|
const viewport = page.getViewport({ scale: renderScale })
|
||||||
|
canvas.style.width = (viewport.width / RENDER_DPR) + 'px'
|
||||||
|
canvas.style.height = (viewport.height / RENDER_DPR) + 'px'
|
||||||
canvas.width = viewport.width
|
canvas.width = viewport.width
|
||||||
canvas.height = viewport.height
|
canvas.height = viewport.height
|
||||||
renderTask = page.render({ canvasContext: context, viewport })
|
const context = canvas.getContext('2d')
|
||||||
renderTask.promise.catch(() => {})
|
try {
|
||||||
})
|
await page.render({ canvasContext: context, viewport, intent: 'display' }).promise
|
||||||
|
} catch (e) {
|
||||||
|
// 渲染被中断(缩放/卸载时)属于正常情况,忽略即可
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(currentPage, () => {
|
function handleScroll() {
|
||||||
renderPage()
|
if (suppressScrollSync || !containerRef.value || totalPages.value === 0) return
|
||||||
})
|
const container = containerRef.value
|
||||||
|
const containerTop = container.getBoundingClientRect().top
|
||||||
|
let bestPage = currentPage.value
|
||||||
|
let bestDistance = Infinity
|
||||||
|
for (const [page, el] of pageRefs.entries()) {
|
||||||
|
if (!el) continue
|
||||||
|
const distance = Math.abs(el.getBoundingClientRect().top - containerTop)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestPage = page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestPage !== currentPage.value) currentPage.value = bestPage
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToPage(page, smooth = true) {
|
||||||
|
const el = pageRefs.get(page)
|
||||||
|
const container = containerRef.value
|
||||||
|
if (!el || !container) return
|
||||||
|
suppressScrollSync = true
|
||||||
|
const targetTop = el.offsetTop - 8
|
||||||
|
container.scrollTo({ top: targetTop, behavior: smooth ? 'smooth' : 'auto' })
|
||||||
|
currentPage.value = page
|
||||||
|
setTimeout(() => { suppressScrollSync = false }, smooth ? 400 : 50)
|
||||||
|
}
|
||||||
|
|
||||||
function prevPage() {
|
function prevPage() {
|
||||||
if (currentPage.value > 1) {
|
if (currentPage.value > 1) {
|
||||||
currentPage.value--
|
scrollToPage(currentPage.value - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
if (currentPage.value < totalPages.value) {
|
if (currentPage.value < totalPages.value) {
|
||||||
currentPage.value++
|
scrollToPage(currentPage.value + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomIn() {
|
function zoomIn() {
|
||||||
scale.value = Math.min(3, scale.value + 0.2)
|
scale.value = Math.min(3, +(scale.value + 0.2).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomOut() {
|
function zoomOut() {
|
||||||
scale.value = Math.max(0.5, scale.value - 0.2)
|
scale.value = Math.max(0.5, +(scale.value - 0.2).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.arrayBuffer, () => {
|
watch(() => props.arrayBuffer, () => {
|
||||||
loadPdf()
|
loadPdf()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(scale, () => {
|
watch(scale, async () => {
|
||||||
renderPage()
|
await renderAllPages()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -130,11 +185,9 @@ onUnmounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--el-text-color-regular);
|
color: var(--el-text-color-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-toolbar button:hover:not(:disabled) {
|
.pdf-toolbar button:hover:not(:disabled) {
|
||||||
background: var(--el-fill-color-light);
|
background: var(--el-fill-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-toolbar span {
|
.pdf-toolbar span {
|
||||||
color: var(--el-text-color-regular);
|
color: var(--el-text-color-regular);
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
@@ -148,11 +201,20 @@ onUnmounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: var(--el-fill-color);
|
background: var(--el-fill-color);
|
||||||
}
|
}
|
||||||
.pdf-canvas-container canvas {
|
.pdf-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pdf-page canvas {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
-ms-interpolation-mode: nearest-neighbor;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<!-- 顶部栏 -->
|
<!-- 顶部栏 -->
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
:agent-name="currentConversation?.agentName || '请选择会话'"
|
:agent-name="currentConversation?.agentName || headerName"
|
||||||
:current-model="currentModel"
|
:current-model="currentModel"
|
||||||
:model-options="modelSelectOptions"
|
:model-options="modelSelectOptions"
|
||||||
:model-value="currentModel?.modelName || currentModel?.name || ''"
|
:model-value="currentModel?.modelName || currentModel?.name || ''"
|
||||||
@@ -187,6 +187,14 @@ const conversationSidebarRef = ref();
|
|||||||
const editorTextareaRef = ref();
|
const editorTextareaRef = ref();
|
||||||
|
|
||||||
const currentConversation = ref(null);
|
const currentConversation = ref(null);
|
||||||
|
// 未选中会话时聊天窗口标题:来自路由 query.name(智能体广场带过来),
|
||||||
|
// 否则展示“新建会话”。
|
||||||
|
const headerName = computed(() => {
|
||||||
|
if (currentConversation.value?.agentName) return currentConversation.value.agentName
|
||||||
|
const fromRoute = route.query.name
|
||||||
|
if (typeof fromRoute === 'string' && fromRoute.trim()) return fromRoute
|
||||||
|
return '新建会话'
|
||||||
|
})
|
||||||
const messages = ref([]);
|
const messages = ref([]);
|
||||||
const quotedFiles = ref([]);
|
const quotedFiles = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -444,7 +452,7 @@ function handleWsMessage(data) {
|
|||||||
currentAssistantMessage = {
|
currentAssistantMessage = {
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: '正在思考...',
|
content: '正在思考',
|
||||||
isThinking: true,
|
isThinking: true,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
@@ -471,7 +479,7 @@ function handleWsMessage(data) {
|
|||||||
messages.value.push(currentAssistantMessage);
|
messages.value.push(currentAssistantMessage);
|
||||||
}
|
}
|
||||||
// 如果当前内容是 "正在思考...",则替换为实际内容(而不是追加),并清除 isThinking 标记
|
// 如果当前内容是 "正在思考...",则替换为实际内容(而不是追加),并清除 isThinking 标记
|
||||||
if (currentAssistantMessage.content === '正在思考...') {
|
if (currentAssistantMessage.content === '正在思考') {
|
||||||
currentAssistantMessage.content = data.content || '';
|
currentAssistantMessage.content = data.content || '';
|
||||||
currentAssistantMessage.isThinking = false;
|
currentAssistantMessage.isThinking = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -553,7 +561,7 @@ function handleWsMessage(data) {
|
|||||||
currentAssistantMessage = {
|
currentAssistantMessage = {
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: '正在思考...',
|
content: '正在思考',
|
||||||
isThinking: true,
|
isThinking: true,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
@@ -756,6 +764,12 @@ async function handleConversationSelect(session) {
|
|||||||
messages.value = [];
|
messages.value = [];
|
||||||
sessionId = session?.sessionId || '';
|
sessionId = session?.sessionId || '';
|
||||||
|
|
||||||
|
// 切换会话前重置上一会话的 tokenUsage 状态,避免 UI 短暂显示旧值
|
||||||
|
contextUsed.value = '0';
|
||||||
|
contextLimit.value = '0';
|
||||||
|
contextPercent.value = 0;
|
||||||
|
tokenUsageData.value = null;
|
||||||
|
|
||||||
if (!session?.sessionId) {
|
if (!session?.sessionId) {
|
||||||
switchingConversation.value = false;
|
switchingConversation.value = false;
|
||||||
return;
|
return;
|
||||||
@@ -766,6 +780,8 @@ async function handleConversationSelect(session) {
|
|||||||
// 返回格式: { msg: "{...}", code: 200 },msg 是字符串需要 parse 两层
|
// 返回格式: { msg: "{...}", code: 200 },msg 是字符串需要 parse 两层
|
||||||
const inner = typeof res.msg === 'string' ? JSON.parse(res.msg) : (res.msg || {});
|
const inner = typeof res.msg === 'string' ? JSON.parse(res.msg) : (res.msg || {});
|
||||||
const rawMessages = Array.isArray(inner) ? inner : (inner.messages || []);
|
const rawMessages = Array.isArray(inner) ? inner : (inner.messages || []);
|
||||||
|
console.log(rawMessages);
|
||||||
|
|
||||||
messages.value = transformMessages(rawMessages);
|
messages.value = transformMessages(rawMessages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取消息列表失败:", error);
|
console.error("获取消息列表失败:", error);
|
||||||
@@ -776,6 +792,8 @@ async function handleConversationSelect(session) {
|
|||||||
switchingConversation.value = false;
|
switchingConversation.value = false;
|
||||||
// 切换会话后滚动到底部
|
// 切换会话后滚动到底部
|
||||||
chatWindowRef.value?.scrollToBottom();
|
chatWindowRef.value?.scrollToBottom();
|
||||||
|
// 进入会话时立即调用一次 tokenUsage(不阻塞消息加载)
|
||||||
|
fetchTokenUsage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<ConversationSidebar :hide-marketplace-btn="true">
|
<ConversationSidebar :hide-marketplace-btn="true">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="marketplace-sidebar-content">
|
<div class="marketplace-sidebar-content">
|
||||||
<div class="marketplace-tabs-header">分类</div>
|
<div class="marketplace-tabs-header">
|
||||||
|
<img :src="logoSrc" alt="智能体" />
|
||||||
|
</div>
|
||||||
<div class="marketplace-tabs">
|
<div class="marketplace-tabs">
|
||||||
<span
|
<span
|
||||||
v-for="tab in categoryTabs"
|
v-for="tab in categoryTabs"
|
||||||
@@ -90,8 +92,12 @@ import { listAgentProjects } from '@/api/frontend'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import ConversationSidebar from './components/ConversationSidebar.vue'
|
import ConversationSidebar from './components/ConversationSidebar.vue'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
import logoName from '@/assets/logo/logo_name.png'
|
||||||
|
import logoNameDark from '@/assets/logo/logo_name_dark.png'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const logoSrc = computed(() => settingsStore.frontendTheme === 'dark' ? logoNameDark : logoName)
|
||||||
|
|
||||||
// 分类标签列表
|
// 分类标签列表
|
||||||
const categoryTabs = [
|
const categoryTabs = [
|
||||||
@@ -251,13 +257,18 @@ onMounted(() => {
|
|||||||
|
|
||||||
.marketplace-tabs-header {
|
.marketplace-tabs-header {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 11px 16px;
|
padding: 5px 10px;
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--fe-text-muted);
|
|
||||||
border-bottom: 1px solid var(--fe-border);
|
border-bottom: 1px solid var(--fe-border);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 180px;
|
||||||
|
height: auto;
|
||||||
|
max-height: 45px;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: left center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.marketplace-tabs {
|
.marketplace-tabs {
|
||||||
|
|||||||
Reference in New Issue
Block a user