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'
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = 数智AI
|
||||
VITE_APP_TITLE = SK Agent
|
||||
|
||||
# 生产环境配置
|
||||
VITE_APP_ENV = 'production'
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = 数智AI
|
||||
VITE_APP_TITLE = SK Agent
|
||||
|
||||
# 生产环境配置
|
||||
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">
|
||||
<!-- 用户消息组 -->
|
||||
<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(() => {
|
||||
const wrap = getScrollWrap()
|
||||
if (!wrap) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
if (wrap.scrollHeight !== lastPolledScrollHeight) {
|
||||
lastPolledScrollHeight = wrap.scrollHeight
|
||||
wrap.scrollTop = wrap.scrollHeight
|
||||
} else if (!props.loading) {
|
||||
// 流式回复期间保持轮询,回复结束且高度稳定后再停止
|
||||
stopPolling()
|
||||
}
|
||||
}, 100)
|
||||
// 贴底:scrollTop 必须是 scrollHeight - clientHeight(最大合法位置)
|
||||
function scrollToBottomOf(wrap) {
|
||||
if (!wrap) return
|
||||
wrap.scrollTop = wrap.scrollHeight - wrap.clientHeight
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
// 启动 MutationObserver:监听消息容器内部 DOM 变化(流式追加/字符变化)
|
||||
// ResizeObserver 只追踪元素自身 box 尺寸,无法感知 scrollable 容器内的内容增长,故不用
|
||||
function setupAutoScroll() {
|
||||
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() {
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user