Compare commits

...
1 Commits
Author SHA1 Message Date
HuangYJ ee3c0e1084 1 2026-08-12 09:17:11 +08:00
15 changed files with 760 additions and 125 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VITE_APP_TITLE = 数智AI
VITE_APP_TITLE = SK Agent
# 开发环境配置
VITE_APP_ENV = 'development'
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VITE_APP_TITLE = 数智AI
VITE_APP_TITLE = SK Agent
# 生产环境配置
VITE_APP_ENV = 'production'
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VITE_APP_TITLE = 数智AI
VITE_APP_TITLE = SK Agent
# 生产环境配置
VITE_APP_ENV = 'staging'
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

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

+268 -71
View File
@@ -5,7 +5,11 @@
<!-- 消息列表 -->
<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-text" v-html="formatContent(group.user.content)"></div>
</div>
@@ -32,7 +36,11 @@
<ArrowDown />
</el-icon>
</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>
<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>
</template>
<template v-else-if="(loading || group.assistant?.isThinking) && groupIndex === groupedMessages.length - 1">
正在思考
<span class="loading-dots">
<span></span><span></span><span></span>
</span>
正在思考...
</template>
</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>
</div>
<!-- 右侧问题定位导航横杆 + 悬停气泡 -->
<MessageNavigator
:questions="userQuestions"
:active-index="activeQuestionIndex"
@select="handleNavigatorSelect"
/>
</div>
</template>
<script setup>
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 { createMarkdownRenderer } from '@/views/frontend/utils/highlighter'
import useSettingsStore from '@/store/modules/settings'
@@ -205,13 +221,99 @@ function toggleGroup(index) {
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) {
return tools ? tools.filter(t => t.kind === 'thinking') : []
}
function getThinkingContent(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) {
@@ -271,18 +373,14 @@ function formatToolResult(content) {
function scrollToBottom() {
nextTick(() => {
const wrap = scrollRef.value?.$refs?.wrapRef
if (wrap) {
wrap.scrollTop = wrap.scrollHeight
}
scrollToBottomOf(getScrollWrap())
})
}
// —— 自动下拉相关状态 ——
const userScrolledUp = ref(false)
const showScrollButton = ref(false)
let pollTimer = null
let lastPolledScrollHeight = 0
let mutationObserver = null
// 距底阈值:< 50px 视为已触底,> 200px 显示向下箭头
const BOTTOM_THRESHOLD = 2
@@ -292,51 +390,58 @@ function getScrollWrap() {
return scrollRef.value?.$refs?.wrapRef
}
// 启动轮询:每 100ms 检测一次 scrollHeight 变化,有变化就滚到底,直到稳定
function startPolling() {
if (pollTimer) return
// 强制第一次迭代滚动(lastPolledScrollHeight 与当前不同)
lastPolledScrollHeight = -1
pollTimer = setInterval(() => {
// 贴底:scrollTop 必须是 scrollHeight - clientHeight(最大合法位置)
function scrollToBottomOf(wrap) {
if (!wrap) return
wrap.scrollTop = wrap.scrollHeight - wrap.clientHeight
}
// 启动 MutationObserver:监听消息容器内部 DOM 变化(流式追加/字符变化)
// ResizeObserver 只追踪元素自身 box 尺寸,无法感知 scrollable 容器内的内容增长,故不用
function setupAutoScroll() {
if (mutationObserver) return
const wrap = getScrollWrap()
if (!wrap) {
stopPolling()
return
}
if (wrap.scrollHeight !== lastPolledScrollHeight) {
lastPolledScrollHeight = wrap.scrollHeight
wrap.scrollTop = wrap.scrollHeight
} else if (!props.loading) {
// 流式回复期间保持轮询,回复结束且高度稳定后再停止
stopPolling()
}
}, 100)
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 stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
function teardownAutoScroll() {
if (mutationObserver) {
mutationObserver.disconnect()
mutationObserver = null
}
}
// 监听用户滚动:触底则开启轮询;任何非触底状态都停止轮询(消除灰色地带抖动)
// 监听用户滚动:触底则恢复自动滚动;任何非触底状态都进入"用户上拉"模式
function handleScroll() {
const wrap = getScrollWrap()
if (!wrap) return
const distance = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight
if (distance <= BOTTOM_THRESHOLD) {
// 已触底:恢复轮询,隐藏箭头
// 已触底:恢复自动滚动,隐藏箭头
userScrolledUp.value = false
showScrollButton.value = false
if (pollTimer === null) startPolling()
} else {
// 任何非触底状态:停止轮询,避免与用户滚动冲突
// 任何非触底状态:标记为上拉,避免与用户滚动冲突
// 仅在距离 > 200px 时显示箭头按钮
stopPolling()
userScrolledUp.value = true
showScrollButton.value = distance > SHOW_BUTTON_THRESHOLD
}
updateActiveQuestionByScroll()
}
// 平滑滚动到底部(自定义时长,easeInOutCubic 缓动)
@@ -372,54 +477,32 @@ function animateScrollToBottom(duration = 500) {
function forceScrollToBottom() {
userScrolledUp.value = false
showScrollButton.value = false
stopPolling()
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const wrap = getScrollWrap()
if (!wrap) return
wrap.scrollTop = wrap.scrollHeight - wrap.clientHeight
lastPolledScrollHeight = wrap.scrollHeight
startPolling()
scrollToBottomOf(getScrollWrap())
})
})
})
}
// 点击向下箭头:0.5s 动画滚动到底,结束后恢复轮询
// 点击向下箭头:0.5s 动画滚动到底
function handleArrowClick() {
userScrolledUp.value = false
showScrollButton.value = false
stopPolling()
animateScrollToBottom(500).then(() => {
if (pollTimer === null) startPolling()
})
animateScrollToBottom(500)
}
// 消息 push 时:未上拉则开始轮询;上拉中只更新高度记录
// 消息 push 时:刷新右侧导航的激活项;自动滚动由 ResizeObserver 接管
watch(() => props.messages.length, () => {
nextTick(() => {
const wrap = getScrollWrap()
if (!wrap) return
if (!userScrolledUp.value) {
lastPolledScrollHeight = wrap.scrollHeight
startPolling()
} else {
// 用户在上拉阅读,记录最新高度但不滚动
lastPolledScrollHeight = wrap.scrollHeight
}
updateActiveQuestionByScroll()
})
})
// loading 变化(开始/结束流式):未上拉则启动轮询
watch(() => props.loading, (val) => {
if (!userScrolledUp.value) {
nextTick(() => {
const wrap = getScrollWrap()
if (wrap) lastPolledScrollHeight = wrap.scrollHeight
startPolling()
})
}
// loading 变化(开始/结束流式):ResizeObserver 在内容尺寸变化时自动贴底,无需额外处理
watch(() => props.loading, () => {
// 占位:保留以备后续扩展(如 loading 转换时的视觉过渡)
})
// When Shiki finishes loading, `formatContent` will pick up the populated
@@ -428,14 +511,13 @@ watch(() => props.loading, (val) => {
onMounted(() => {
nextTick(() => {
const wrap = getScrollWrap()
if (wrap) lastPolledScrollHeight = wrap.scrollHeight
setupAutoScroll()
scrollToBottom()
})
})
onUnmounted(() => {
stopPolling()
teardownAutoScroll()
})
defineExpose({ scrollToBottom, forceScrollToBottom })
@@ -532,7 +614,7 @@ defineExpose({ scrollToBottom, forceScrollToBottom })
color: var(--fe-text-primary);
border-radius: 10px 10px 10px 4px;
box-shadow: var(--fe-shadow-sm);
max-width: 70%;
max-width: 90%;
}
}
@@ -585,6 +667,56 @@ defineExpose({ scrollToBottom, forceScrollToBottom })
:deep(a) {
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);
}
.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 {
margin-left: auto;
transition: transform 0.2s;
@@ -9,7 +9,9 @@
<div v-if="!collapsed" class="sidebar-content">
<slot name="content">
<div class="sidebar-header">
<span>会话列表</span>
<div class="marketplace-tabs-header">
<img :src="logoSrc" alt="智能体" />
</div>
<div class="header-actions">
<el-tooltip content="新建会话" placement="top" :show-after="300">
<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 { ElMessage, ElMessageBox } from 'element-plus'
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 settingsStore = useSettingsStore()
const logoSrc = computed(() => settingsStore.frontendTheme === 'dark' ? logoNameDark : logoName)
const collapsed = ref(false)
const sessionList = ref([])
const currentSession = ref(null)
@@ -398,14 +405,22 @@ defineExpose({
.sidebar-header {
box-sizing: border-box;
padding: 11px 16px;
padding: 5px 16px 5px 12px;
font-weight: 600;
border-bottom: 1px solid var(--fe-border);
display: flex;
justify-content: space-between;
align-items: center;
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 {
display: flex;
gap: 4px;
+1 -1
View File
@@ -34,7 +34,7 @@
<div v-else-if="fileTree.length === 0" class="empty-wrapper">
<span>暂无文件</span>
</div>
<el-scrollbar v-else height="calc(100vh - 200px)">
<el-scrollbar v-else height="calc(100vh - 120px)">
<el-tree
:data="fileTree"
: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>
+97 -35
View File
@@ -7,18 +7,25 @@
<button @click="zoomOut" :disabled="scale <= 0.5">缩小</button>
<button @click="zoomIn" :disabled="scale >= 3">放大</button>
</div>
<div class="pdf-canvas-container" ref="containerRef">
<canvas ref="canvasRef"></canvas>
<div class="pdf-canvas-container" ref="containerRef" @scroll.passive="handleScroll">
<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>
</template>
<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 PdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url'
// 设置 worker
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker
const props = defineProps({
@@ -26,74 +33,122 @@ const props = defineProps({
})
const containerRef = ref(null)
const canvasRef = ref(null)
const currentPage = ref(1)
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 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() {
if (pdfDoc) {
try { pdfDoc.destroy() } catch (e) {}
pdfDoc = null
}
pageRefs.clear()
canvasRefs.clear()
const loadingTask = pdfjsLib.getDocument({ data: props.arrayBuffer })
pdfDoc = await loadingTask.promise
totalPages.value = pdfDoc.numPages
currentPage.value = 1
renderPage()
await nextTick()
await renderAllPages()
scrollToPage(1, false)
}
function renderPage() {
if (!pdfDoc || !canvasRef.value) return
if (renderTask) {
renderTask.cancel()
renderTask = null
async function renderAllPages() {
if (!pdfDoc || !containerRef.value) return
const pages = []
for (let i = 1; i <= pdfDoc.numPages; i++) {
pages.push(pdfDoc.getPage(i).then(page => ({ index: i, page })))
}
pdfDoc.getPage(currentPage.value).then(page => {
const viewport = page.getViewport({ scale: scale.value })
const canvas = canvasRef.value
const context = canvas.getContext('2d')
canvas.style.width = viewport.width + 'px'
canvas.style.height = viewport.height + 'px'
const results = await Promise.all(pages)
for (const { index, page } of results) {
const canvas = canvasRefs.get(index)
if (!canvas) continue
const renderScale = scale.value * RENDER_DPR
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.height = viewport.height
renderTask = page.render({ canvasContext: context, viewport })
renderTask.promise.catch(() => {})
})
const context = canvas.getContext('2d')
try {
await page.render({ canvasContext: context, viewport, intent: 'display' }).promise
} catch (e) {
// 渲染被中断(缩放/卸载时)属于正常情况,忽略即可
}
}
}
watch(currentPage, () => {
renderPage()
})
function handleScroll() {
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() {
if (currentPage.value > 1) {
currentPage.value--
scrollToPage(currentPage.value - 1)
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
scrollToPage(currentPage.value + 1)
}
}
function zoomIn() {
scale.value = Math.min(3, scale.value + 0.2)
scale.value = Math.min(3, +(scale.value + 0.2).toFixed(2))
}
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, () => {
loadPdf()
})
watch(scale, () => {
renderPage()
watch(scale, async () => {
await renderAllPages()
})
onMounted(() => {
@@ -130,11 +185,9 @@ onUnmounted(() => {
cursor: pointer;
color: var(--el-text-color-regular);
}
.pdf-toolbar button:hover:not(:disabled) {
background: var(--el-fill-color-light);
}
.pdf-toolbar span {
color: var(--el-text-color-regular);
min-width: 60px;
@@ -148,11 +201,20 @@ onUnmounted(() => {
flex: 1;
overflow: auto;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 16px;
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);
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
}
</style>
+22 -4
View File
@@ -11,7 +11,7 @@
<div class="main-content">
<!-- 顶部栏 -->
<ChatHeader
:agent-name="currentConversation?.agentName || '请选择会话'"
:agent-name="currentConversation?.agentName || headerName"
:current-model="currentModel"
:model-options="modelSelectOptions"
:model-value="currentModel?.modelName || currentModel?.name || ''"
@@ -187,6 +187,14 @@ const conversationSidebarRef = ref();
const editorTextareaRef = ref();
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 quotedFiles = ref([]);
const loading = ref(false);
@@ -444,7 +452,7 @@ function handleWsMessage(data) {
currentAssistantMessage = {
kind: 'text',
role: 'assistant',
content: '正在思考...',
content: '正在思考',
isThinking: true,
timestamp: Date.now()
};
@@ -471,7 +479,7 @@ function handleWsMessage(data) {
messages.value.push(currentAssistantMessage);
}
// 如果当前内容是 "正在思考...",则替换为实际内容(而不是追加),并清除 isThinking 标记
if (currentAssistantMessage.content === '正在思考...') {
if (currentAssistantMessage.content === '正在思考') {
currentAssistantMessage.content = data.content || '';
currentAssistantMessage.isThinking = false;
} else {
@@ -553,7 +561,7 @@ function handleWsMessage(data) {
currentAssistantMessage = {
kind: 'text',
role: 'assistant',
content: '正在思考...',
content: '正在思考',
isThinking: true,
timestamp: Date.now()
};
@@ -756,6 +764,12 @@ async function handleConversationSelect(session) {
messages.value = [];
sessionId = session?.sessionId || '';
// 切换会话前重置上一会话的 tokenUsage 状态,避免 UI 短暂显示旧值
contextUsed.value = '0';
contextLimit.value = '0';
contextPercent.value = 0;
tokenUsageData.value = null;
if (!session?.sessionId) {
switchingConversation.value = false;
return;
@@ -766,6 +780,8 @@ async function handleConversationSelect(session) {
// 返回格式: { msg: "{...}", code: 200 }msg 是字符串需要 parse 两层
const inner = typeof res.msg === 'string' ? JSON.parse(res.msg) : (res.msg || {});
const rawMessages = Array.isArray(inner) ? inner : (inner.messages || []);
console.log(rawMessages);
messages.value = transformMessages(rawMessages);
} catch (error) {
console.error("获取消息列表失败:", error);
@@ -776,6 +792,8 @@ async function handleConversationSelect(session) {
switchingConversation.value = false;
// 切换会话后滚动到底部
chatWindowRef.value?.scrollToBottom();
// 进入会话时立即调用一次 tokenUsage(不阻塞消息加载)
fetchTokenUsage();
}
}
+18 -7
View File
@@ -4,7 +4,9 @@
<ConversationSidebar :hide-marketplace-btn="true">
<template #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">
<span
v-for="tab in categoryTabs"
@@ -90,8 +92,12 @@ import { listAgentProjects } from '@/api/frontend'
import { ElMessage } from 'element-plus'
import ConversationSidebar from './components/ConversationSidebar.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 router = useRouter()
const settingsStore = useSettingsStore()
const logoSrc = computed(() => settingsStore.frontendTheme === 'dark' ? logoNameDark : logoName)
// 分类标签列表
const categoryTabs = [
@@ -251,13 +257,18 @@ onMounted(() => {
.marketplace-tabs-header {
box-sizing: border-box;
padding: 11px 16px;
font-size: 12px;
font-weight: 600;
color: var(--fe-text-muted);
padding: 5px 10px;
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 {