Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee3c0e1084 | ||
|
|
e1a4558a49 | ||
|
|
eae1e31c23 | ||
|
|
1a57a4f2e9 | ||
|
|
ba19b553f3 | ||
|
|
e25a137e26 |
@@ -1,5 +1,5 @@
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = 数智AI
|
||||
VITE_APP_TITLE = SK Agent
|
||||
|
||||
# 开发环境配置
|
||||
VITE_APP_ENV = 'development'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = 数智AI
|
||||
VITE_APP_TITLE = SK Agent
|
||||
|
||||
# 生产环境配置
|
||||
VITE_APP_ENV = 'production'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = 数智AI
|
||||
VITE_APP_TITLE = SK Agent
|
||||
|
||||
# 生产环境配置
|
||||
VITE_APP_ENV = 'staging'
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
# Frontend 深色主题 + 会话搜索/分组 + 聊天收紧 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 frontend 页面调整为:石墨黑 + 蓝紫 accent 的深色主题、会话侧栏顶部"搜索会话"输入框、按 `今天 / 昨天 / 更早` 分组且"当前会话所在分组自动展开、其他折叠"、聊天模块气泡节奏收紧对齐 chat.png。本次仅修改本地代码,不做 git 推送。
|
||||
|
||||
**Architecture:** 三个文件局部改动,不新增组件、不引入新依赖。`theme.scss` 替换 `:root` 变量值;`ConversationSidebar.vue` 在 header 与 scrollbar 之间插入搜索框,scrollbar 内部从一维列表改为分组结构,添加 `groupOf / parseTimestamp / sessionGroups / expandedGroups / syncExpandedWithCurrent` 等本地逻辑;`ChatWindow.vue` 仅调整气泡相关 SCSS。所有改动通过 `--fe-*` 变量串联,浅色主题不受影响。
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Composition API + Element Plus 2.13 + Sass(sass-embedded 1.97)。测试使用 Vitest(需在 Task 1 中确认是否已配置)。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 操作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/views/frontend/styles/theme.scss` | 改 | 仅替换 `:root` 下 `--fe-*` 值;其它块保持原样 |
|
||||
| `src/views/frontend/components/ConversationSidebar.vue` | 改 | header 下新增搜索框;scrollbar 内分组渲染;新增本地状态/方法;新增分组标题样式 |
|
||||
| `src/views/frontend/components/ChatWindow.vue` | 改 | 仅 `<style scoped>` 内调整 `padding / margin-bottom / border-radius` |
|
||||
|
||||
无新增文件;不新增组件抽象。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 摸底测试栈与现有测试位置
|
||||
|
||||
**Files:**
|
||||
- Inspect: `package.json`, `vitest.config.*`, `tests/`、`src/**/*.test.{js,ts}`
|
||||
|
||||
- [ ] **Step 1: 检查 package.json 是否声明了测试框架**
|
||||
|
||||
```bash
|
||||
cd "D:/数科智联/项目/agent-frontend-web"
|
||||
grep -E "vitest|jest|@vue/test-utils" package.json
|
||||
```
|
||||
|
||||
预期:很可能没有测试框架;如果 `npm ls vitest` 无输出,跳过 vitest 步骤(前端为视觉改动,以手动 dev 验证为主)。记录结论到 Task 1 末尾的"测试结论"。
|
||||
|
||||
- [ ] **Step 2: 若无测试框架,标注结论并跳过所有 TDD 步骤**
|
||||
|
||||
在执行 Task 2~4 时,把"Write the failing test / Run it to verify it fails"两步替换为"手动 dev 验证(启动 `npm run dev` 在浏览器查看)"。
|
||||
|
||||
测试结论:当前仓库未配置前端单元测试;本次采用手动 dev 验证(步骤在 Task 5 统一列出)。
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 替换 `theme.scss` 深色调色板
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/styles/theme.scss:3-46`(`:root` 块)
|
||||
|
||||
- [ ] **Step 1: 替换 `:root` 变量值**
|
||||
|
||||
将 `src/views/frontend/styles/theme.scss` 的 `:root { ... }` 块整体替换为以下内容(变量名不变,仅改值):
|
||||
|
||||
```scss
|
||||
:root {
|
||||
// —— 基础色 ——
|
||||
--fe-bg-base: #14171c;
|
||||
--fe-bg-elevated: #1c1f24;
|
||||
--fe-bg-overlay: #23272e;
|
||||
--fe-bg-input: #2a2e35;
|
||||
--fe-bg-hover: #1f2329;
|
||||
|
||||
// —— 边框 ——
|
||||
--fe-border: #2c3138;
|
||||
--fe-border-strong: #4a505a;
|
||||
|
||||
// —— 文本 ——
|
||||
--fe-text-primary: #e4e6eb;
|
||||
--fe-text-secondary: #9aa0a6;
|
||||
--fe-text-muted: #6b7280;
|
||||
--fe-text-inverse: #14171c;
|
||||
|
||||
// —— 品牌色 ——
|
||||
--fe-accent: #6c8cff;
|
||||
--fe-accent-hover: #8aa3ff;
|
||||
--fe-accent-soft: rgba(108, 140, 255, 0.15);
|
||||
--fe-danger: #f07178;
|
||||
--fe-danger-soft: rgba(240, 113, 120, 0.1);
|
||||
--fe-warning: #e0a050;
|
||||
--fe-info: #6cc4d6;
|
||||
--fe-info-soft: rgba(108, 196, 214, 0.1);
|
||||
--fe-info-border: rgba(108, 196, 214, 0.4);
|
||||
--fe-accent-border: rgba(108, 140, 255, 0.4);
|
||||
--fe-danger-border: rgba(240, 113, 120, 0.4);
|
||||
|
||||
// —— 布局尺寸 ——
|
||||
--fe-sidebar-width: 260px;
|
||||
--fe-file-panel-width: 280px;
|
||||
--fe-file-preview-width: 600px;
|
||||
|
||||
// —— 阴影 / 圆角 ——
|
||||
--fe-radius-sm: 4px;
|
||||
--fe-radius-md: 6px;
|
||||
--fe-radius-lg: 12px;
|
||||
--fe-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--fe-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.55);
|
||||
--fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 校验未触碰其它块**
|
||||
|
||||
```bash
|
||||
cd "D:/数科智联/项目/agent-frontend-web"
|
||||
sed -n '47,90p' src/views/frontend/styles/theme.scss
|
||||
```
|
||||
|
||||
预期:`body[data-fe-theme="light"]` 块保持 `body[data-fe-theme="light"] { --fe-bg-base: #ffffff; ... }` 原样输出。
|
||||
|
||||
- [ ] **Step 3: 校验 `.frontend-container` 与浅色 el-* 块未改**
|
||||
|
||||
```bash
|
||||
cd "D:/数科智联/项目/agent-frontend-web"
|
||||
grep -n "frontend-container" src/views/frontend/styles/theme.scss
|
||||
```
|
||||
|
||||
预期:仍有两处 `.frontend-container`(不含 light 子选择器与 light 修饰),行号与改前一致。
|
||||
|
||||
- [ ] **Step 4: 启动 dev,目测深色页面底色与 accent 是否变为石墨黑/蓝紫**
|
||||
|
||||
```bash
|
||||
cd "D:/数科智联/项目/agent-frontend-web"
|
||||
npm run dev
|
||||
```
|
||||
|
||||
打开 `/frontend` 路由,深色主题下容器底色变 `#14171c`、accent 高亮变 `#6c8cff`;切到浅色主题,颜色与之前完全一致。
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `ConversationSidebar.vue` —— 模板新增搜索框 + 分组渲染
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/components/ConversationSidebar.vue:10-59`(模板)、`:372-`(.conversation-item 圆角)
|
||||
|
||||
- [ ] **Step 1: 在 `sidebar-header` 之后插入搜索框**
|
||||
|
||||
在 `<div class="sidebar-header">...</div>` 之后、`<el-scrollbar class="sidebar-scrollbar">` 之前,插入:
|
||||
|
||||
```html
|
||||
<div class="sidebar-search">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索会话"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 把 scrollbar 内的列表改成分组渲染**
|
||||
|
||||
将 `<div v-for="session in sessionList" ...>` 整段替换为:
|
||||
|
||||
```html
|
||||
<template v-for="group in sessionGroups" :key="group.key">
|
||||
<template v-if="group.items.length > 0">
|
||||
<div class="group-header" @click="toggleGroup(group.key)">
|
||||
<span class="group-title">{{ group.label }}</span>
|
||||
<el-icon class="group-toggle">
|
||||
<ArrowDown v-if="expandedGroups.has(group.key)" />
|
||||
<ArrowRight v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div v-show="expandedGroups.has(group.key)" class="group-items">
|
||||
<div
|
||||
v-for="session in group.items"
|
||||
:key="session.sessionId"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentSession?.sessionId === session.sessionId, 'has-new-message': session.hasNewMessage }"
|
||||
@click="selectSession(session)"
|
||||
@contextmenu.prevent="handleRightClick($event, session)"
|
||||
>
|
||||
<div class="conv-dot"></div>
|
||||
<div class="conv-info">
|
||||
<span class="conv-name">{{ session.title || session.name }}</span>
|
||||
<span class="conv-time">{{ formatTimeAgo(session.updateTime) }}</span>
|
||||
</div>
|
||||
<div v-if="session.hasNewMessage" class="new-message-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
```
|
||||
|
||||
空列表提示 `<div v-if="!loading && sessionList.length === 0" class="empty-sessions">暂无会话</div>` 保持不变。
|
||||
|
||||
- [ ] **Step 3: 把 `.conversation-item` 的固定 8px 圆角改为变量**
|
||||
|
||||
在 `<style scoped lang="scss">` 中找到:
|
||||
|
||||
```scss
|
||||
.conversation-item {
|
||||
...
|
||||
border-radius: 8px;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
将 `border-radius: 8px;` 改为 `border-radius: var(--fe-radius-md);`。
|
||||
|
||||
- [ ] **Step 4: 在 `<style scoped lang="scss">` 内追加搜索框与分组样式**
|
||||
|
||||
紧跟在 `.sidebar-header { ... }` 块之后追加:
|
||||
|
||||
```scss
|
||||
.sidebar-search {
|
||||
padding: 8px 12px 0;
|
||||
:deep(.el-input__wrapper) {
|
||||
background: var(--fe-bg-elevated);
|
||||
box-shadow: 0 0 0 1px var(--fe-border) inset;
|
||||
border-radius: var(--fe-radius-md);
|
||||
}
|
||||
:deep(.el-input__inner) {
|
||||
color: var(--fe-text-primary);
|
||||
&::placeholder { color: var(--fe-text-muted); }
|
||||
}
|
||||
:deep(.el-input__prefix .el-icon) { color: var(--fe-text-muted); }
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fe-accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.group-toggle {
|
||||
color: var(--fe-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
&:hover .group-toggle { color: var(--fe-text-primary); }
|
||||
}
|
||||
|
||||
.group-items { padding: 0 8px; }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: dev 验证(任务3)**
|
||||
|
||||
启动 dev,进入 `/frontend`,目测:
|
||||
- header 下方出现搜索框;
|
||||
- 列表按 `今天 / 昨天 / 更早` 三段渲染;
|
||||
- 分组标题为蓝紫色,左侧 ▾/▸ 图标。
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `ConversationSidebar.vue` —— 脚本新增分组逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/components/ConversationSidebar.vue:68-262`(script setup)
|
||||
|
||||
- [ ] **Step 1: 扩展 icon 导入**
|
||||
|
||||
将:
|
||||
|
||||
```js
|
||||
import { DArrowLeft, DArrowRight, Refresh, Plus, Delete, Loading } from '@element-plus/icons-vue'
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```js
|
||||
import { DArrowLeft, DArrowRight, Refresh, Plus, Delete, Loading, Search, ArrowDown, ArrowRight } from '@element-plus/icons-vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 扩展 import,引入 computed**
|
||||
|
||||
将 `import { ref, onMounted, onUnmounted } from 'vue'` 改为 `import { ref, computed, onMounted, onUnmounted } from 'vue'`。
|
||||
|
||||
- [ ] **Step 3: 新增 state 与 helper,放在 `currentSession` 之后**
|
||||
|
||||
在 `const currentSession = ref(null)` 之后插入:
|
||||
|
||||
```js
|
||||
// —— 搜索关键字(仅样式占位,本次不绑定行为) ——
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// —— 分组折叠状态:'today' | 'yesterday' | 'earlier' ——
|
||||
const expandedGroups = ref(new Set())
|
||||
|
||||
function parseTimestamp(t) {
|
||||
if (!t) return null
|
||||
if (typeof t === 'number' && String(t).length === 13) return t
|
||||
if (typeof t === 'number' || /^\d{14}$/.test(t)) {
|
||||
const s = String(t)
|
||||
return new Date(`${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}T${s.slice(8,10)}:${s.slice(10,12)}:${s.slice(12,14)}`).getTime()
|
||||
}
|
||||
if (typeof t === 'string' && t.includes(' ')) {
|
||||
const iso = t.replace(' ', 'T').replace(/\.(\d+)?$/, '')
|
||||
const ms = new Date(iso).getTime()
|
||||
return Number.isNaN(ms) ? null : ms
|
||||
}
|
||||
const ms = new Date(t).getTime()
|
||||
return Number.isNaN(ms) ? null : ms
|
||||
}
|
||||
|
||||
function groupOf(session) {
|
||||
const ts = parseTimestamp(session?.updateTime)
|
||||
if (ts == null) {
|
||||
console.warn('[ConversationSidebar] 无法解析会话时间', session)
|
||||
return 'earlier'
|
||||
}
|
||||
const now = new Date()
|
||||
const d = new Date(ts)
|
||||
const sameDay = (a, b) =>
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1)
|
||||
if (sameDay(d, now)) return 'today'
|
||||
if (sameDay(d, yesterday)) return 'yesterday'
|
||||
return 'earlier'
|
||||
}
|
||||
|
||||
const sessionGroups = computed(() => {
|
||||
const groups = { today: [], yesterday: [], earlier: [] }
|
||||
for (const s of sessionList.value) {
|
||||
groups[groupOf(s)].push(s)
|
||||
}
|
||||
return [
|
||||
{ key: 'today', label: '今天', items: groups.today },
|
||||
{ key: 'yesterday', label: '昨天', items: groups.yesterday },
|
||||
{ key: 'earlier', label: '更早', items: groups.earlier }
|
||||
]
|
||||
})
|
||||
|
||||
function toggleGroup(key) {
|
||||
const next = new Set(expandedGroups.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
expandedGroups.value = next
|
||||
}
|
||||
|
||||
function syncExpandedWithCurrent() {
|
||||
if (currentSession.value) {
|
||||
expandedGroups.value = new Set([groupOf(currentSession.value)])
|
||||
} else {
|
||||
expandedGroups.value = new Set(['today'])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 `selectSession` 末尾追加 `syncExpandedWithCurrent()`**
|
||||
|
||||
将:
|
||||
|
||||
```js
|
||||
function selectSession(session) {
|
||||
currentSession.value = session
|
||||
emit('select', session)
|
||||
}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```js
|
||||
function selectSession(session) {
|
||||
currentSession.value = session
|
||||
emit('select', session)
|
||||
syncExpandedWithCurrent()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在 `fetchSessions` 的 `finally` 中调用 `syncExpandedWithCurrent()`**
|
||||
|
||||
将 `fetchSessions` 函数体中 `finally` 块改为:
|
||||
|
||||
```js
|
||||
} finally {
|
||||
// 强制最小 loading 时长,避免接口返回过快导致 icon 一闪而过
|
||||
const elapsed = Date.now() - start
|
||||
const remain = 300 - elapsed
|
||||
if (remain > 0) await new Promise(resolve => setTimeout(resolve, remain))
|
||||
loading.value = false
|
||||
syncExpandedWithCurrent()
|
||||
emit('ready')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 在 `handleDeleteSession` 删除条目后追加 `syncExpandedWithCurrent()`**
|
||||
|
||||
找到:
|
||||
|
||||
```js
|
||||
if (currentSession.value?.sessionId === session.sessionId) {
|
||||
currentSession.value = null
|
||||
emit('select', null)
|
||||
}
|
||||
```
|
||||
|
||||
在它之后追加:
|
||||
|
||||
```js
|
||||
syncExpandedWithCurrent()
|
||||
```
|
||||
|
||||
- [ ] **Step 7: dev 验证(任务4)**
|
||||
|
||||
启动 dev,进入 `/frontend`:
|
||||
- 列表加载完成后默认 `今天` 展开、昨天 / 更早折叠;
|
||||
- 点击任一会话 → 仅其所在分组展开;
|
||||
- 点击分组标题 → 切换展开 / 折叠。
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `ChatWindow.vue` —— 气泡节奏收紧
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/components/ChatWindow.vue`(`<style scoped lang="scss">` 段)
|
||||
|
||||
- [ ] **Step 1: 收紧容器与消息组**
|
||||
|
||||
在 `<style scoped lang="scss">` 中定位 `.chat-window` 与 `.message-group`,将:
|
||||
|
||||
```scss
|
||||
.chat-window {
|
||||
...
|
||||
padding: 16px 20px;
|
||||
...
|
||||
}
|
||||
|
||||
.message-group {
|
||||
margin-bottom: 12px;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
分别改为:
|
||||
|
||||
```scss
|
||||
.chat-window {
|
||||
...
|
||||
padding: 12px 16px;
|
||||
...
|
||||
}
|
||||
|
||||
.message-group {
|
||||
margin-bottom: 10px;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 收紧气泡**
|
||||
|
||||
定位 `.message-bubble`(user / assistant 共享样式),将 `padding: 10px 14px;` 改为 `padding: 9px 13px;`;将 `border-radius: 12px;` 改为 `border-radius: 10px;`。
|
||||
|
||||
- [ ] **Step 3: dev 验证(任务5)**
|
||||
|
||||
启动 dev,进入 `/frontend`,选择一个会话发消息,目测:
|
||||
- 用户 / 助手气泡圆角 10px;
|
||||
- 气泡内边距 9 / 13px;
|
||||
- 消息组间距 10px;
|
||||
- 整体节奏与 `chat.png` 一致;
|
||||
- 发送消息后流式追加仍自动滚到底。
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 端到端冒烟清单(合并验证)
|
||||
|
||||
**Files:** 无新增;仅执行 dev 自检。
|
||||
|
||||
- [ ] **Step 1: 深色主题**
|
||||
|
||||
进入 `/frontend` 默认深色:
|
||||
- 容器底色 `#14171c`、卡片背景 `#1c1f24`;
|
||||
- ChatHeader、ConversationSidebar、ChatWindow、InputArea 颜色一致;
|
||||
- accent 高亮 `#6c8cff`(搜索框聚焦边框、分组标题、新建会话图标等)。
|
||||
|
||||
- [ ] **Step 2: 浅色主题未变**
|
||||
|
||||
切到浅色主题:底色 `#ffffff`、accent `#1890ff`;与改动前完全一致。
|
||||
|
||||
- [ ] **Step 3: 上下文 popover / 模型选择 / ElMessageBox**
|
||||
|
||||
点击 ChatHeader 切换模型、点击 InputArea 右下角上下文指示器、删除会话触发确认弹窗:新深色系跟随;浅色下保持原样。
|
||||
|
||||
- [ ] **Step 4: 侧栏分组 + 搜索框**
|
||||
|
||||
- 列表加载默认 `今天` 展开;
|
||||
- 点击分组标题切换展开 / 折叠;
|
||||
- 选中任一会话 → 仅其所在分组展开;
|
||||
- 删除当前会话后折叠状态回退到 `今天`(或当前会话所在分组);
|
||||
- 搜索框聚焦高亮、placeholder "搜索会话"、输入不改变列表;
|
||||
- 右键菜单仍可重命名 / 删除会话。
|
||||
|
||||
- [ ] **Step 5: 聊天模块**
|
||||
|
||||
- 用户 / 助手气泡圆角 10px、内边距 9/13px;
|
||||
- 消息组间距 10px;
|
||||
- 流式追加自动滚到底;
|
||||
- 上拉后向下箭头按钮仍可点击回到底部;
|
||||
- 处理详情折叠面板(thinking / tool)展开 / 折叠正常。
|
||||
|
||||
- [ ] **Step 6: 不做 git 推送**
|
||||
|
||||
本次仅修改本地文件,不运行 `git push` / `git commit`(如果用户希望记录变更,可手动提交)。
|
||||
@@ -0,0 +1,542 @@
|
||||
# Frontend 顶部重排 + 搜索高亮 + 分组默认展开 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 ChatHeader 顶部右侧改为"清空 icon + 模型下拉 + 文件面板折叠 icon";标题前加 agentName 首字圆形 icon;文件面板默认展开;侧栏搜索框聚焦高亮;分组默认全部展开。
|
||||
|
||||
**Architecture:** 三个文件局部改动,不新增组件。ChatHeader 改为 props-driven(`modelOptions`、`modelValue`、`filePanelVisible`),通过 `update:modelValue` 与 `toggle-file-panel` 两个事件与父组件通讯。ConversationSidebar 调整 `syncExpandedWithCurrent` 默认行为并补充搜索框 `:focus` 样式。index.vue 默认 `filePanelVisible = true`、新增 `handleModelSelectChange` / `handleToggleFilePanel`、删除 `handleTabChange` 与 `setActiveTab` 调用。LlmModelModal 文件保留不引用。
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Composition API + Element Plus 2.13(`el-select` / `el-option` / `Fold` / `Expand` / `Delete` 图标)+ Sass(sass-embedded 1.97)。无前端测试框架,沿用手动 dev 验证(Task 5)。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 操作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/views/frontend/components/ChatHeader.vue` | 改 | 模板重排(avatar / clear icon / model select / sidebar fold icon);脚本替换(LlmModelModal 移除、新增 modelOptions 等 props、单一 update:modelValue 通道);样式新增(.agent-avatar / .icon-btn / .model-select) |
|
||||
| `src/views/frontend/components/ConversationSidebar.vue` | 改 | `.sidebar-search` 追加 `:focus` 样式;`syncExpandedWithCurrent` 改为全部展开 |
|
||||
| `src/views/frontend/index.vue` | 改 | `filePanelVisible` 默认 `true`;删除 `handleTabChange`;`handleFilePanelClose` 中移除 `setActiveTab` 调用;新增 `modelSelectOptions` computed、`handleModelSelectChange`、`handleToggleFilePanel`;模板 ChatHeader props 与事件调整 |
|
||||
| `src/views/frontend/components/LlmModelModal.vue` | 保留 | 文件保留,未来如需"更多模型"入口再启用 |
|
||||
|
||||
无新增文件;不新增组件抽象。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: ChatHeader —— props 与事件契约改造
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/components/ChatHeader.vue:33-67`(`<script setup>` 段)
|
||||
|
||||
- [ ] **Step 1: 替换 icon 导入**
|
||||
|
||||
将:
|
||||
|
||||
```js
|
||||
import { Cpu } from '@element-plus/icons-vue'
|
||||
import LlmModelModal from './LlmModelModal.vue'
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```js
|
||||
import { Delete, Fold, Expand } from '@element-plus/icons-vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 删除 LlmModelModal 引用与 tab 状态**
|
||||
|
||||
删除以下声明(出现在 `import` 之后、`defineProps` 之前):
|
||||
|
||||
```js
|
||||
const activeTab = ref('agent')
|
||||
const showModelModal = ref(false)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 扩展 props**
|
||||
|
||||
将:
|
||||
|
||||
```js
|
||||
const props = defineProps({
|
||||
agentName: {
|
||||
type: String,
|
||||
default: '请选择会话'
|
||||
},
|
||||
currentModel: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```js
|
||||
const props = defineProps({
|
||||
agentName: {
|
||||
type: String,
|
||||
default: '请选择会话'
|
||||
},
|
||||
currentModel: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
modelOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
filePanelVisible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 替换 emits**
|
||||
|
||||
将:
|
||||
|
||||
```js
|
||||
const emit = defineEmits(['tab-change', 'model-change', 'clear'])
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```js
|
||||
const emit = defineEmits(['clear', 'update:modelValue', 'toggle-file-panel'])
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 删除 tab/modal 相关函数、删除 expose,新增 `agentNameInitial` / `handleSelectChange` / `handleToggleClick`**
|
||||
|
||||
将整个 `<script setup>` 中除 `defineProps` / `defineEmits` / icon 导入之外的所有函数(含 `handleTabClick` / `handleModelSelect` / `defineExpose` 块)替换为:
|
||||
|
||||
```js
|
||||
const agentNameInitial = computed(() => {
|
||||
const name = (props.agentName || '').trim()
|
||||
if (!name) return '?'
|
||||
// 中文取首字,英文取首字母
|
||||
return [...name][0]
|
||||
})
|
||||
|
||||
function handleSelectChange(value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function handleToggleClick() {
|
||||
emit('toggle-file-panel')
|
||||
}
|
||||
```
|
||||
|
||||
并在 `<script setup>` 顶部补充 `import { computed } from 'vue'`(如果尚未导入)。
|
||||
|
||||
---
|
||||
|
||||
## Task 2: ChatHeader —— 模板替换
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/components/ChatHeader.vue:0-31`(`<template>` 段)
|
||||
|
||||
- [ ] **Step 1: 整段替换 `<template>` 内容**
|
||||
|
||||
将 `<template>` 块从:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="chat-header">
|
||||
<div class="header-left">
|
||||
<span class="conversation-name">{{ agentName }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- 清空会话 -->
|
||||
<el-button @click="emit('clear')">
|
||||
清空会话
|
||||
</el-button>
|
||||
<!-- 模型选择 -->
|
||||
<el-button @click="showModelModal = true">
|
||||
{{ currentModel?.name || currentModel?.modelName || '选择模型' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="activeTab === 'agent' ? 'primary' : 'default'"
|
||||
@click="handleTabClick('agent')"
|
||||
>
|
||||
智能体
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="activeTab === 'file' ? 'primary' : 'default'"
|
||||
@click="handleTabClick('file')"
|
||||
>
|
||||
文件
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择弹窗 -->
|
||||
<LlmModelModal v-model="showModelModal" @select="handleModelSelect" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="chat-header">
|
||||
<div class="header-left">
|
||||
<div class="agent-avatar">{{ agentNameInitial }}</div>
|
||||
<span class="conversation-name">{{ agentName }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-tooltip content="清空会话" placement="bottom" :show-after="300">
|
||||
<el-button class="icon-btn" link @click="emit('clear')">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-select
|
||||
class="model-select"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="handleSelectChange"
|
||||
placeholder="选择模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in modelOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-tooltip
|
||||
:content="filePanelVisible ? '收起文件面板' : '展开文件面板'"
|
||||
placement="bottom"
|
||||
:show-after="300"
|
||||
>
|
||||
<el-button class="icon-btn" link @click="handleToggleClick">
|
||||
<el-icon>
|
||||
<component :is="filePanelVisible ? Fold : Expand" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: ChatHeader —— 样式新增
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/components/ChatHeader.vue`(`<style scoped lang="scss">` 段)
|
||||
|
||||
- [ ] **Step 1: 重写 `<style scoped lang="scss">` 内容**
|
||||
|
||||
将现有 `<style scoped lang="scss">` 整段替换为:
|
||||
|
||||
```scss
|
||||
.chat-header {
|
||||
padding: 11px;
|
||||
border-bottom: 1px solid var(--fe-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--fe-bg-elevated);
|
||||
color: var(--fe-text-primary);
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 480px;
|
||||
|
||||
.agent-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--fe-accent);
|
||||
color: var(--fe-text-inverse);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.icon-btn {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--fe-text-secondary);
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--fe-radius-md);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--fe-bg-hover);
|
||||
color: var(--fe-text-primary);
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-select {
|
||||
width: 180px;
|
||||
:deep(.el-select__wrapper) {
|
||||
background: var(--fe-bg-elevated);
|
||||
box-shadow: 0 0 0 1px var(--fe-border) inset;
|
||||
border-radius: var(--fe-radius-md);
|
||||
}
|
||||
:deep(.el-select__placeholder) { color: var(--fe-text-muted); }
|
||||
:deep(.el-select__selected-item) { color: var(--fe-text-primary); }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: ConversationSidebar —— 搜索框聚焦高亮
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/components/ConversationSidebar.vue`(`<style scoped lang="scss">` 中 `.sidebar-search` 块)
|
||||
|
||||
- [ ] **Step 1: 在 `.sidebar-search` 内追加 `:focus` 规则**
|
||||
|
||||
找到:
|
||||
|
||||
```scss
|
||||
.sidebar-search {
|
||||
padding: 8px 12px 0;
|
||||
:deep(.el-input__wrapper) {
|
||||
background: var(--fe-bg-elevated);
|
||||
box-shadow: 0 0 0 1px var(--fe-border) inset;
|
||||
border-radius: var(--fe-radius-md);
|
||||
}
|
||||
:deep(.el-input__inner) {
|
||||
color: var(--fe-text-primary);
|
||||
&::placeholder { color: var(--fe-text-muted); }
|
||||
}
|
||||
:deep(.el-input__prefix .el-icon) { color: var(--fe-text-muted); }
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```scss
|
||||
.sidebar-search {
|
||||
padding: 8px 12px 0;
|
||||
:deep(.el-input__wrapper) {
|
||||
background: var(--fe-bg-elevated);
|
||||
box-shadow: 0 0 0 1px var(--fe-border) inset;
|
||||
border-radius: var(--fe-radius-md);
|
||||
transition: box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--fe-accent) inset;
|
||||
background: var(--fe-bg-elevated);
|
||||
}
|
||||
:deep(.el-input__inner) {
|
||||
color: var(--fe-text-primary);
|
||||
&::placeholder { color: var(--fe-text-muted); }
|
||||
}
|
||||
:deep(.el-input__wrapper.is-focus .el-input__inner::placeholder) {
|
||||
color: var(--fe-text-secondary);
|
||||
}
|
||||
:deep(.el-input__prefix .el-icon) { color: var(--fe-text-muted); }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: ConversationSidebar —— 分组默认全部展开
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/components/ConversationSidebar.vue`(`<script setup>` 中 `syncExpandedWithCurrent`)
|
||||
|
||||
- [ ] **Step 1: 重写 `syncExpandedWithCurrent` 函数体**
|
||||
|
||||
将:
|
||||
|
||||
```js
|
||||
function syncExpandedWithCurrent() {
|
||||
if (currentSession.value) {
|
||||
expandedGroups.value = new Set([groupOf(currentSession.value)])
|
||||
} else {
|
||||
expandedGroups.value = new Set(['today'])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```js
|
||||
function syncExpandedWithCurrent() {
|
||||
// 用户要求默认全部展开;用户可点击分组标题手动收/展
|
||||
expandedGroups.value = new Set(['today', 'yesterday', 'earlier'])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: index.vue —— 文件面板默认展开 + 新增模型切换与折叠处理
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/views/frontend/index.vue`
|
||||
|
||||
- [ ] **Step 1: 文件面板默认 `true`**
|
||||
|
||||
将 `const filePanelVisible = ref(false);` 改为 `const filePanelVisible = ref(true);`。
|
||||
|
||||
- [ ] **Step 2: 新增 `modelSelectOptions` computed 与 `handleModelSelectChange`**
|
||||
|
||||
在 `currentModel = ref(null)` 附近、`initModels` 之前,插入:
|
||||
|
||||
```js
|
||||
const modelSelectOptions = computed(() => modelList.value.map(m => ({
|
||||
label: m.name || m.modelName || '',
|
||||
value: m.modelName || m.name || ''
|
||||
})))
|
||||
|
||||
function handleModelSelectChange(value) {
|
||||
const found = modelList.value.find(m => (m.modelName || m.name) === value)
|
||||
if (found) {
|
||||
currentModel.value = found
|
||||
ElMessage.success(`已选择模型: ${found.name || found.modelName}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 新增 `handleToggleFilePanel`**
|
||||
|
||||
在 `handleModelChange` 之后插入:
|
||||
|
||||
```js
|
||||
function handleToggleFilePanel() {
|
||||
filePanelVisible.value = !filePanelVisible.value
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 删除 `handleTabChange` 函数体**
|
||||
|
||||
将 `handleTabChange` 整个函数(含之前的 `function handleTabChange(tab) { ... }`)连同其上方空行一起删除。
|
||||
|
||||
- [ ] **Step 5: 删除 `handleFilePanelClose` 中的 `setActiveTab` 调用**
|
||||
|
||||
将:
|
||||
|
||||
```js
|
||||
function handleFilePanelClose() {
|
||||
filePanelVisible.value = false;
|
||||
previewVisible.value = false;
|
||||
chatHeaderRef.value?.setActiveTab('agent');
|
||||
}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```js
|
||||
function handleFilePanelClose() {
|
||||
filePanelVisible.value = false;
|
||||
previewVisible.value = false;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 替换模板上的 `<ChatHeader>` 标签**
|
||||
|
||||
将:
|
||||
|
||||
```html
|
||||
<ChatHeader
|
||||
ref="chatHeaderRef"
|
||||
:agent-name="currentConversation?.agentName || '请选择会话'"
|
||||
:current-model="currentModel"
|
||||
@tab-change="handleTabChange"
|
||||
@model-change="handleModelChange"
|
||||
@clear="handleClear"
|
||||
/>
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```html
|
||||
<ChatHeader
|
||||
ref="chatHeaderRef"
|
||||
:agent-name="currentConversation?.agentName || '请选择会话'"
|
||||
:current-model="currentModel"
|
||||
:model-options="modelSelectOptions"
|
||||
:model-value="currentModel?.modelName || currentModel?.name || ''"
|
||||
:file-panel-visible="filePanelVisible"
|
||||
@update:model-value="handleModelSelectChange"
|
||||
@toggle-file-panel="handleToggleFilePanel"
|
||||
@clear="handleClear"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 端到端冒烟清单
|
||||
|
||||
**Files:** 无新增;仅执行 dev 自检。
|
||||
|
||||
- [ ] **Step 1: ChatHeader 视觉**
|
||||
|
||||
启动 `npm run dev`,进入 `/frontend`,目测:
|
||||
- 左侧 `agent-avatar` 圆形 icon(accent 背景、白字、显示 agentName 首字),后跟 agentName;
|
||||
- 右侧依次:垃圾桶 icon(清空)、模型 el-select 下拉、文件面板折叠 icon(默认 Fold,因为 filePanelVisible 默认 true);
|
||||
- 不再有 "智能体" / "文件" 两个 tab 按钮;不再有 `LlmModelModal` 弹窗入口。
|
||||
|
||||
- [ ] **Step 2: 文件面板默认展开 + icon 切换**
|
||||
|
||||
- 页面加载后文件面板可见;
|
||||
- 点击 ChatHeader 右侧 Fold icon → 文件面板收起,icon 变为 Expand;
|
||||
- 再点 Expand icon → 文件面板展开,icon 变回 Fold。
|
||||
|
||||
- [ ] **Step 3: 模型下拉**
|
||||
|
||||
- el-select 显示当前模型名称;
|
||||
- 切换下拉 → 立即生效(chatWindow 后续发送消息会带上新模型);
|
||||
- `ElMessage` 提示"已选择模型: …"。
|
||||
|
||||
- [ ] **Step 4: 清空 icon**
|
||||
|
||||
- 点击垃圾桶 icon → WebSocket 发送 `clear` 类型消息,聊天区消息清空;
|
||||
- 控制台无 error。
|
||||
|
||||
- [ ] **Step 5: 搜索框聚焦高亮**
|
||||
|
||||
- 点击侧栏搜索框 → 输入框边框变为 accent 色;
|
||||
- placeholder 颜色由 muted 变 secondary;
|
||||
- 失焦恢复。
|
||||
|
||||
- [ ] **Step 6: 分组默认全部展开**
|
||||
|
||||
- 页面加载后 今天 / 昨天 / 更早 三段同时展开;
|
||||
- 切换会话后仍全部展开(不回到"仅当前分组");
|
||||
- 点击任一分组标题可手动折叠;切换会话或刷新会重置为全部展开。
|
||||
|
||||
- [ ] **Step 7: 不做 git 推送**
|
||||
|
||||
本次仅修改本地文件,不运行 `git push` / `git commit`(用户自行决定是否提交)。
|
||||
@@ -0,0 +1,59 @@
|
||||
# Frontend 上下文窗口 popover 主题适配 — Design Spec
|
||||
|
||||
**日期:** 2026-08-07
|
||||
**状态:** Approved
|
||||
**范围:** Bug 修复(CSS 变量 / Element Plus Teleport)
|
||||
|
||||
## 背景
|
||||
|
||||
`src/views/frontend/components/InputArea.vue` 中的"上下文窗口"指示器(`.context-indicator`)点击后弹出 `el-popover`,其中显示 token 使用量、阈值百分比等。当前问题是:该 popover 没有适配 frontend 的深 / 浅色主题。
|
||||
|
||||
## 根因
|
||||
|
||||
Element Plus 的 `el-popover` 默认 `teleported: true`(已确认,源码 `element-plus/es/components/tooltip/src/content.mjs`),弹出层被 Teleport 到 body 之外。
|
||||
|
||||
`body[data-fe-theme="light"]` 只覆盖了 `--fe-*` 系列变量,未覆盖 Element Plus 内部用到的 `--el-bg-color-overlay`、`--el-border-color-lighter`、`--el-text-color-regular` 等。结果是:
|
||||
|
||||
- popover 容器(`.el-popper`)回退到 Element Plus 默认值(浅色背景 + 浅色文本)
|
||||
- 在深色主题下,与 frontend 整体深色风格不一致
|
||||
- 而 `.context-popover` 内部的 `--fe-*` 变量虽然继承自 `:root`,但 popover 容器本身的背景 / 边框 / 文字色来自 `--el-*`,这些没被覆盖
|
||||
|
||||
## 方案
|
||||
|
||||
**给 `el-popover` 设置 `:teleported="false"`**,让 popover 容器保留在 `.frontend-container` DOM 树内,从而继承 `.frontend-container` 下的所有 `--el-*` / `--fe-*` 变量映射。
|
||||
|
||||
### 改动点
|
||||
|
||||
`src/views/frontend/components/InputArea.vue` 第 81-85 行的 `el-popover` 标签,新增 `:teleported="false"`:
|
||||
|
||||
```vue
|
||||
<el-popover
|
||||
placement="top"
|
||||
:width="280"
|
||||
trigger="click"
|
||||
:teleported="false"
|
||||
>
|
||||
```
|
||||
|
||||
`.context-popover` 现有的 scoped 样式(`.popover-title` / `.popover-body` / `.popover-footer` / `.token-count`)保持不变,因为它们使用 `--fe-*` 变量,本来就能工作。
|
||||
|
||||
### 不需要改动
|
||||
|
||||
- `theme.scss`:`.frontend-container` 已经把 `--el-bg-color-overlay`、`--el-text-color-regular`、`--el-color-success`、`--el-color-danger` 等都映射到 `--fe-*` 变量,只要 popover 在 `.frontend-container` 内就能自动生效
|
||||
- 浅色覆盖:`.frontend-container[data-theme="light"]` 同样完整覆盖了 `--el-*` 系列
|
||||
- `.context-popover` 的 scoped 样式:因为 `.context-popover` 本身在 InputArea 模板中,scoped 的 `data-v-xxx` 仍会附加到元素上,popover 留在组件树内后所有选择器自然匹配
|
||||
|
||||
## 验证
|
||||
|
||||
1. 切到深色主题,点击输入框右下角的"XX%"指示器 → popover 应为深色背景(`#252526` / `--fe-bg-elevated`)、浅色文字
|
||||
2. 切到浅色主题,点击同一指示器 → popover 应为白色(`#ffffff` / `--el-bg-color`)、深色文字
|
||||
3. 百分比 `el-tag`:深色主题下 `success` 是青色(`#4ec9b0`),`danger` 是珊瑚红(`#f48771`);浅色主题下 `success` 是蓝色(`#1890ff`),`danger` 是红色(`#f56c6c`)
|
||||
4. `token-count` 文字:深色下青色,浅色下蓝色
|
||||
5. 重复点击 / 多次切换主题,popover 位置不漂移、不被聊天内容遮挡
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改 `el-popover` 的其它属性(placement、width、trigger)
|
||||
- 不改 `theme.scss`
|
||||
- 不改 `.context-popover` 现有样式(变量已经够用)
|
||||
- 不动 `el-tooltip`(左下角 `@` 按钮的 tooltip,独立组件,独立处理)
|
||||
@@ -0,0 +1,271 @@
|
||||
# Frontend 深色主题 + 会话搜索/分组 + 聊天模块收紧 — Design Spec
|
||||
|
||||
**日期:** 2026-08-08
|
||||
**状态:** Approved
|
||||
**范围:** UI 调整(仅本地代码,不做 git 推送)
|
||||
|
||||
## 背景
|
||||
|
||||
根目录提供两张参考图:
|
||||
|
||||
- `image.png` — frontend 整体布局:深色主题 + 侧栏"会话列表"标题 + 下方按时间分组的会话项 + 右侧聊天主区;
|
||||
- `chat.png` — 聊天区细节:用户消息右侧气泡、青色主题色、深色背景、紧凑留白。
|
||||
|
||||
当前 `src/views/frontend/` 已具备 `theme.scss`、`ConversationSidebar.vue`、`ChatWindow.vue` 的完整骨架,但:
|
||||
|
||||
1. 深色 `--fe-*` 变量停留在首版调色(VS Code 暗色 + 青色 accent),与 image.png 期望的"冷感石墨黑 + 蓝紫 accent"仍有差距;
|
||||
2. 会话侧栏只有一维列表,没有"今天 / 昨天 / 更早"分组,也没有搜索入口;
|
||||
3. 聊天模块的内边距、圆角与 chat.png 的紧凑节奏仍有可收敛空间。
|
||||
|
||||
## 目标 / 非目标
|
||||
|
||||
**目标**
|
||||
|
||||
- 把 `:root` 下 `--fe-*` 替换为石墨黑底 + 蓝紫 accent;
|
||||
- 会话侧栏顶部新增"搜索会话"输入框(仅样式);
|
||||
- 会话按 `今天 / 昨天 / 更早` 分组,分组支持展开/折叠,且"当前会话所在分组自动展开,其他折叠";
|
||||
- 聊天模块气泡、消息组、滚动按钮的圆角与内边距收紧,与 chat.png 节奏对齐。
|
||||
|
||||
**非目标**
|
||||
|
||||
- 不修改浅色主题(`body[data-fe-theme="light"]` 与 `.frontend-container[data-theme="light"]`);
|
||||
- 不修改 WebSocket、流式消息、自动下拉、模型选择、上下文 popover、`UserArea`、右键菜单、其它视图;
|
||||
- 不引入新接口;搜索框不绑定行为;分组折叠状态不持久化。
|
||||
|
||||
## 改动清单
|
||||
|
||||
### 1. `src/views/frontend/styles/theme.scss`
|
||||
|
||||
替换 `:root` 块下所有 `--fe-*` 变量值,保持变量名不变。`body[data-fe-theme="light"]`、`.frontend-container { --el-* }`、`.frontend-container[data-theme="light"]` 三个块完全不动。`.frontend-el-message-box` 与全局 `.context-menu` 由于使用 `--fe-*` 变量,自动跟随新调色板,不需要修改。
|
||||
|
||||
```scss
|
||||
:root {
|
||||
// —— 基础色 ——
|
||||
--fe-bg-base: #14171c; // 容器底色(石墨黑)
|
||||
--fe-bg-elevated: #1c1f24; // 卡片 / hover
|
||||
--fe-bg-overlay: #23272e; // 弹层 / 高亮
|
||||
--fe-bg-input: #2a2e35; // 输入区背景
|
||||
--fe-bg-hover: #1f2329; // 行 hover
|
||||
|
||||
// —— 边框 ——
|
||||
--fe-border: #2c3138;
|
||||
--fe-border-strong: #4a505a;
|
||||
|
||||
// —— 文本 ——
|
||||
--fe-text-primary: #e4e6eb;
|
||||
--fe-text-secondary: #9aa0a6;
|
||||
--fe-text-muted: #6b7280;
|
||||
--fe-text-inverse: #14171c;
|
||||
|
||||
// —— 品牌色 ——
|
||||
--fe-accent: #6c8cff; // 蓝紫 accent
|
||||
--fe-accent-hover: #8aa3ff;
|
||||
--fe-accent-soft: rgba(108, 140, 255, 0.15);
|
||||
--fe-danger: #f07178;
|
||||
--fe-danger-soft: rgba(240, 113, 120, 0.1);
|
||||
--fe-warning: #e0a050;
|
||||
--fe-info: #6cc4d6;
|
||||
--fe-info-soft: rgba(108, 196, 214, 0.1);
|
||||
--fe-info-border: rgba(108, 196, 214, 0.4);
|
||||
--fe-accent-border: rgba(108, 140, 255, 0.4);
|
||||
--fe-danger-border: rgba(240, 113, 120, 0.4);
|
||||
|
||||
// —— 布局尺寸 ——
|
||||
--fe-sidebar-width: 260px;
|
||||
--fe-file-panel-width: 280px;
|
||||
--fe-file-preview-width: 600px;
|
||||
|
||||
// —— 阴影 / 圆角 ——
|
||||
--fe-radius-sm: 4px;
|
||||
--fe-radius-md: 6px;
|
||||
--fe-radius-lg: 12px;
|
||||
--fe-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--fe-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.55);
|
||||
--fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `src/views/frontend/components/ConversationSidebar.vue`
|
||||
|
||||
#### 模板变更
|
||||
|
||||
- 在 `sidebar-header` 之后新增 `<div class="sidebar-search">`,内含 `<el-input v-model="searchKeyword" placeholder="搜索会话">`,前缀图标 `Search`;
|
||||
- `<el-scrollbar>` 内部从 `v-for="session in sessionList"` 改为 `v-for="group in sessionGroups" :key="group.key"`:
|
||||
- 每个 `group.items.length === 0` 不渲染;
|
||||
- `<div class="group-header" @click="toggleGroup(group.key)">` 包含 `<span class="group-title">{{ group.label }}</span>` 与 `<el-icon class="group-toggle"><ArrowDown v-if="expandedGroups.has(group.key)" /><ArrowRight v-else /></el-icon>`;
|
||||
- `<div v-show="expandedGroups.has(group.key)" class="group-items">` 渲染 `v-for="session in group.items"`:复用现有 `.conversation-item` 结构。
|
||||
|
||||
#### 脚本新增
|
||||
|
||||
```js
|
||||
import { Search, ArrowDown, ArrowRight } from '@element-plus/icons-vue'
|
||||
|
||||
const searchKeyword = ref('') // 仅留口子,本次不绑定行为
|
||||
const expandedGroups = ref(new Set()) // 'today' | 'yesterday' | 'earlier'
|
||||
|
||||
function groupOf(session) {
|
||||
const ts = parseTimestamp(session?.updateTime)
|
||||
if (ts == null) return 'earlier'
|
||||
const now = new Date()
|
||||
const d = new Date(ts)
|
||||
const sameDay = (a, b) =>
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1)
|
||||
if (sameDay(d, now)) return 'today'
|
||||
if (sameDay(d, yesterday)) return 'yesterday'
|
||||
return 'earlier'
|
||||
}
|
||||
|
||||
function parseTimestamp(t) {
|
||||
if (!t) return null
|
||||
if (typeof t === 'number' && String(t).length === 13) return t
|
||||
if (typeof t === 'number' || /^\d{14}$/.test(t)) {
|
||||
const s = String(t)
|
||||
return new Date(`${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}T${s.slice(8,10)}:${s.slice(10,12)}:${s.slice(12,14)}`).getTime()
|
||||
}
|
||||
if (typeof t === 'string' && t.includes(' ')) {
|
||||
const iso = t.replace(' ', 'T').replace(/\.(\d+)?$/, '')
|
||||
const ms = new Date(iso).getTime()
|
||||
return Number.isNaN(ms) ? null : ms
|
||||
}
|
||||
const ms = new Date(t).getTime()
|
||||
return Number.isNaN(ms) ? null : ms
|
||||
}
|
||||
|
||||
const sessionGroups = computed(() => {
|
||||
const groups = { today: [], yesterday: [], earlier: [] }
|
||||
for (const s of sessionList.value) {
|
||||
groups[groupOf(s)].push(s)
|
||||
}
|
||||
return [
|
||||
{ key: 'today', label: '今天', items: groups.today },
|
||||
{ key: 'yesterday', label: '昨天', items: groups.yesterday },
|
||||
{ key: 'earlier', label: '更早', items: groups.earlier }
|
||||
]
|
||||
})
|
||||
|
||||
function toggleGroup(key) {
|
||||
const next = new Set(expandedGroups.value)
|
||||
if (next.has(key)) next.delete(key); else next.add(key)
|
||||
expandedGroups.value = next
|
||||
}
|
||||
|
||||
function syncExpandedWithCurrent() {
|
||||
if (currentSession.value) {
|
||||
expandedGroups.value = new Set([groupOf(currentSession.value)])
|
||||
} else {
|
||||
expandedGroups.value = new Set(['today'])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
调用点:
|
||||
|
||||
- `fetchSessions` `finally` 中调用 `syncExpandedWithCurrent()`(含首屏无会话、列表为空、列表非空三种情况);
|
||||
- `selectSession(session)` 末尾追加 `syncExpandedWithCurrent()`;
|
||||
- `handleDeleteSession` 移除条目后调用 `syncExpandedWithCurrent()`。
|
||||
|
||||
#### 样式新增(仅作用域内)
|
||||
|
||||
```scss
|
||||
.sidebar-search {
|
||||
padding: 8px 12px 0;
|
||||
:deep(.el-input__wrapper) {
|
||||
background: var(--fe-bg-elevated);
|
||||
box-shadow: 0 0 0 1px var(--fe-border) inset;
|
||||
border-radius: var(--fe-radius-md);
|
||||
}
|
||||
:deep(.el-input__inner) {
|
||||
color: var(--fe-text-primary);
|
||||
&::placeholder { color: var(--fe-text-muted); }
|
||||
}
|
||||
:deep(.el-input__prefix .el-icon) { color: var(--fe-text-muted); }
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fe-accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.group-toggle {
|
||||
color: var(--fe-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
&:hover .group-toggle { color: var(--fe-text-primary); }
|
||||
}
|
||||
|
||||
.group-items { padding: 0 8px; }
|
||||
```
|
||||
|
||||
保留现有 `.conversation-item` 圆角 8px → 改为 `var(--fe-radius-md)`,与 `--fe-radius-md` 同步到 6px。
|
||||
|
||||
### 3. `src/views/frontend/components/ChatWindow.vue`
|
||||
|
||||
仅改 `<style scoped lang="scss">`:
|
||||
|
||||
```scss
|
||||
.chat-window {
|
||||
// padding: 16px 20px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.message-group {
|
||||
// margin-bottom: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
// padding: 10px 14px;
|
||||
padding: 9px 13px;
|
||||
// border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
```
|
||||
|
||||
模板、`<script setup>`、`watch`、`onMounted`、`process-panel`、`scroll-to-bottom` 全部保持原样;`.scroll-to-bottom` 的颜色/阴影通过 `--fe-bg-elevated` / `--fe-shadow-md` 自动跟随新调色。
|
||||
|
||||
## 数据流
|
||||
|
||||
1. `ConversationSidebar.fetchSessions()` → `sessionList` 更新;
|
||||
2. `sessionGroups`(computed)按 `updateTime` 派生今天 / 昨天 / 更早;
|
||||
3. `syncExpandedWithCurrent()` 决定 `expandedGroups`;
|
||||
4. 模板渲染:分组标题 + 当前展开分组内的会话项。
|
||||
|
||||
聊天模块无新增数据流,仅样式收紧。
|
||||
|
||||
## 异常处理
|
||||
|
||||
- `updateTime` 无法解析 → `parseTimestamp` 返回 `null` → `groupOf` 落回 `'earlier'`;控制台 `console.warn('无法解析会话时间', session)` 一次;
|
||||
- 折叠 Set 为空 → `syncExpandedWithCurrent` 总能产出至少一个 key(`'today'` 或当前会话所在分组);
|
||||
- 搜索框无异常路径:用户输入不会改变列表。
|
||||
|
||||
## 验证
|
||||
|
||||
| 步骤 | 期望 |
|
||||
|---|---|
|
||||
| 1 | 启动 dev,深色主题下 frontend 容器底色变 `#14171c`,accent 高亮变 `#6c8cff` |
|
||||
| 2 | 切到浅色主题:颜色与之前完全一致(`#ffffff` / `#1890ff`) |
|
||||
| 3 | 上下文 popover / ChatHeader 模型选择 / `ElMessageBox` 弹窗主题一致(新色系跟随) |
|
||||
| 4 | 列表加载完成后默认 `今天` 展开、昨天 / 更早折叠 |
|
||||
| 5 | 点击任一会话 → 仅其所在分组展开 |
|
||||
| 6 | 点击分组标题 → 切换展开 / 折叠 |
|
||||
| 7 | 搜索框:聚焦高亮、placeholder "搜索会话"、输入无任何列表变化 |
|
||||
| 8 | 聊天区:用户 / 助手气泡圆角 10px、内边距 9/13px;消息间距 10px;与 chat.png 节奏一致 |
|
||||
| 9 | 发消息后流式追加仍自动滚到底;不回归 |
|
||||
| 10 | 删会话后折叠状态保持 `今天`(或当前会话所在分组) |
|
||||
|
||||
## 风险
|
||||
|
||||
- 颜色变量变更可能与前端测试中硬编码颜色(如 `ChatWindow.vue` 内的 `color="#67C23A"` 等 icon 颜色)冲突;本次只动 `--fe-*` 全局变量,硬编码 icon 颜色保留原值;
|
||||
- `parseTimestamp` 仅识别现有 3 种格式(毫秒 / 14 位 / 空格分隔),与 `ChatWindow.formatTime` 保持一致;其它格式落回 "更早" 不报错;
|
||||
- 不做 git 推送;用户在本地合并 / 推送。
|
||||
@@ -0,0 +1,276 @@
|
||||
# Frontend 顶部重排 + 搜索高亮 + 分组默认展开 Design Spec
|
||||
|
||||
**日期:** 2026-08-08
|
||||
**状态:** Approved
|
||||
**范围:** UI 调整(仅本地代码,不做 git 推送)
|
||||
**前置:** 已完成 docs/superpowers/specs/2026-08-08-frontend-dark-theme-sidebar-search-chat-design.md 的深色主题 + 会话搜索/分组 + 聊天气泡节奏改动。本 spec 在此基础上扩展。
|
||||
|
||||
## 背景
|
||||
|
||||
根目录 `chat.png` 中聊天区顶部为:`[agent avatar] [agent name] …… [icon: clear] [model select] [icon: collapse]`。
|
||||
|
||||
当前 `ChatHeader.vue` 顶部右侧有四个按钮:清空会话 / 选择模型 / 智能体 / 文件。"智能体" / "文件" 是互斥 tab,会同时影响 `filePanelVisible` 与 `previewVisible`。本次去掉两个 tab,换成一个独立的"折叠文件面板"icon,让文件面板默认展开,由 icon 控制显隐。
|
||||
|
||||
同时修正:
|
||||
- 搜索框聚焦时仅边框变 accent、placeholder 略亮(已配置样式但未生效);
|
||||
- 会话分组默认全部展开(当前默认仅当前会话所在分组展开)。
|
||||
|
||||
## 目标 / 非目标
|
||||
|
||||
**目标**
|
||||
|
||||
- ChatHeader 顶部右侧:`[icon: clear]` + `[el-select: model]` + `[icon: sidebar-fold/unfold]`;
|
||||
- 标题前加 `agentName` 首字圆形 icon(accent 背景,白色字);
|
||||
- 文件面板默认展开,点击 sidebar-fold icon 折叠,再点 sidebar-unfold icon 展开;
|
||||
- 搜索框聚焦高亮边框与 placeholder;
|
||||
- 会话分组默认全部展开(`today / yesterday / earlier`);
|
||||
- 移除 `LlmModelModal` 在 ChatHeader 中的引用(文件保留);
|
||||
- 移除 `handleTabChange` 中"agent"分支与 `setActiveTab` 调用点。
|
||||
|
||||
**非目标**
|
||||
|
||||
- 不改 WebSocket、流式消息、自动下拉、`InputArea`、`UserArea`、右键菜单、`ChatWindow`、浅色主题、`theme.scss`;
|
||||
- 不持久化文件面板折叠状态、模型选择状态;
|
||||
- 不动后端 `listLlmModels`、`getLlmAngent` 等接口;
|
||||
- 不在本次引入 `useFilePanelStore` / `useModelStore`(保持父组件持有状态)。
|
||||
|
||||
## 改动清单
|
||||
|
||||
### 1. `src/views/frontend/components/ChatHeader.vue`
|
||||
|
||||
#### 模板变更
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="chat-header">
|
||||
<div class="header-left">
|
||||
<div class="agent-avatar">{{ agentNameInitial }}</div>
|
||||
<span class="conversation-name">{{ agentName }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-tooltip content="清空会话" placement="bottom" :show-after="300">
|
||||
<el-button class="icon-btn" link @click="emit('clear')">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-select
|
||||
class="model-select"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="handleSelectChange"
|
||||
size="default"
|
||||
placeholder="选择模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in modelOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-tooltip :content="filePanelVisible ? '收起文件面板' : '展开文件面板'" placement="bottom" :show-after="300">
|
||||
<el-button class="icon-btn" link @click="handleToggleClick">
|
||||
<el-icon><component :is="filePanelVisible ? Fold : Expand" /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
> 注:Element Plus 图标库中"面板折叠 / 展开"对应的图标名为 `Fold` / `Expand`(无 `SidebarFold` / `SidebarUnfold` 同名图标)。语义与"收起 / 展开文件面板"完全对应。
|
||||
```
|
||||
|
||||
#### 脚本变更
|
||||
|
||||
- `import { Delete, Fold, Expand } from '@element-plus/icons-vue'`;
|
||||
- 删除 `import LlmModelModal from './LlmModelModal.vue'`、`activeTab` ref、`showModelModal` ref、`handleTabClick` 函数、`setActiveTab` expose 方法;
|
||||
- 删除 `emit('tab-change', tab)`;
|
||||
- 新增 props:`modelOptions: Array<{label, value}>`、`modelValue: String`、`filePanelVisible: Boolean`;
|
||||
- 新增 emits:`update:modelValue`、`toggle-file-panel`;
|
||||
- 新增 computed `agentNameInitial`:取 `agentName` 的第一个字符(中文取首字、英文取首字母),为空时返回 `?`;
|
||||
- 新增 `handleSelectChange(value)`:emit `update:modelValue`;
|
||||
- 新增 `handleToggleClick()`:emit `toggle-file-panel`;
|
||||
- 删除 `handleModelSelect(model)` 与 `model-change` emit——切换模型统一通过 `update:modelValue` 单事件流,避免重复。
|
||||
|
||||
#### 样式新增
|
||||
|
||||
```scss
|
||||
.agent-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--fe-accent);
|
||||
color: var(--fe-text-inverse);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--fe-text-secondary);
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--fe-radius-md);
|
||||
&:hover {
|
||||
background: var(--fe-bg-hover);
|
||||
color: var(--fe-text-primary);
|
||||
}
|
||||
.el-icon { font-size: 18px; }
|
||||
}
|
||||
|
||||
.model-select {
|
||||
width: 180px;
|
||||
:deep(.el-select__wrapper) {
|
||||
background: var(--fe-bg-elevated);
|
||||
box-shadow: 0 0 0 1px var(--fe-border) inset;
|
||||
border-radius: var(--fe-radius-md);
|
||||
}
|
||||
:deep(.el-select__placeholder) { color: var(--fe-text-muted); }
|
||||
:deep(.el-select__selected-item) { color: var(--fe-text-primary); }
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `src/views/frontend/components/ConversationSidebar.vue`
|
||||
|
||||
#### 搜索框聚焦高亮
|
||||
|
||||
在已有 `.sidebar-search` 块内追加:
|
||||
|
||||
```scss
|
||||
.sidebar-search {
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--fe-accent) inset;
|
||||
background: var(--fe-bg-elevated);
|
||||
}
|
||||
:deep(.el-input__wrapper.is-focus .el-input__inner::placeholder) {
|
||||
color: var(--fe-text-secondary);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 分组默认全部展开
|
||||
|
||||
修改 `syncExpandedWithCurrent`:
|
||||
|
||||
```js
|
||||
function syncExpandedWithCurrent() {
|
||||
// 用户要求默认全部展开;当前会话所在分组由图标高亮区分,无需特殊收/展
|
||||
expandedGroups.value = new Set(['today', 'yesterday', 'earlier'])
|
||||
}
|
||||
```
|
||||
|
||||
`toggleGroup` 行为保持不变——用户可手动收/展某个分组,刷新或切换会话后会重置为全部展开。
|
||||
|
||||
### 3. `src/views/frontend/index.vue`
|
||||
|
||||
#### 状态修改
|
||||
|
||||
```js
|
||||
const filePanelVisible = ref(true) // 原为 false
|
||||
```
|
||||
|
||||
#### 删除 / 新增函数
|
||||
|
||||
- 整体删除 `handleTabChange` 函数体(index.vue 模板上 `@tab-change="..."` 也删除);
|
||||
- 删除 `handleFilePanelClose` 中对 `chatHeaderRef.value?.setActiveTab('agent')` 的调用(仅保留函数体中 `filePanelVisible.value = false; previewVisible.value = false`);
|
||||
- 删除模板上 `@model-change="handleModelChange"`、`@clear` 保留;改用 `@update:model-value="handleModelSelectChange"` 单一通道;
|
||||
- 新增 `handleToggleFilePanel()`:
|
||||
|
||||
```js
|
||||
function handleToggleFilePanel() {
|
||||
filePanelVisible.value = !filePanelVisible.value
|
||||
}
|
||||
```
|
||||
|
||||
#### 模板调整
|
||||
|
||||
把 `<ChatHeader>` 改为:
|
||||
|
||||
```html
|
||||
<ChatHeader
|
||||
ref="chatHeaderRef"
|
||||
:agent-name="currentConversation?.agentName || '请选择会话'"
|
||||
:current-model="currentModel"
|
||||
:model-options="modelSelectOptions"
|
||||
:model-value="currentModel?.modelName || currentModel?.name || ''"
|
||||
:file-panel-visible="filePanelVisible"
|
||||
@update:model-value="handleModelSelectChange"
|
||||
@toggle-file-panel="handleToggleFilePanel"
|
||||
@clear="handleClear"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 脚本新增
|
||||
|
||||
```js
|
||||
const modelSelectOptions = computed(() => modelList.value.map(m => ({
|
||||
label: m.name || m.modelName || '',
|
||||
value: m.modelName || m.name || ''
|
||||
})))
|
||||
|
||||
function handleModelSelectChange(value) {
|
||||
const found = modelList.value.find(m => (m.modelName || m.name) === value)
|
||||
if (found) {
|
||||
currentModel.value = found
|
||||
ElMessage.success(`已选择模型: ${found.name || found.modelName}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `src/views/frontend/components/LlmModelModal.vue`
|
||||
|
||||
不修改,保留文件以备后续扩展(不再从 ChatHeader 引用)。
|
||||
|
||||
## 数据流
|
||||
|
||||
1. `index.vue` 持有 `currentModel` / `modelList` / `filePanelVisible`;
|
||||
2. 把 `modelSelectOptions` 与 `model-value` 传给 ChatHeader;
|
||||
3. ChatHeader `el-select` change → emit `update:modelValue(value)` → 父按 value 在 `modelList` 中查找匹配项 → 写 `currentModel` → emit `model-change`;
|
||||
4. ChatHeader 点击折叠 icon → emit `toggle-file-panel` → 父翻转 `filePanelVisible`;
|
||||
5. 父根据 `filePanelVisible` 渲染 `<FilePanel>`。
|
||||
|
||||
## 异常处理
|
||||
|
||||
- `modelList` 为空:`el-select` 下拉空选项,不报错;
|
||||
- `currentModel` 找不到匹配项:`handleModelSelectChange` 静默不更新,保留旧值;
|
||||
- `agentName` 为空:`agent-avatar` 显示 `?`;
|
||||
- 文件面板折叠 / 展开不持久化,刷新页面后默认展开;
|
||||
- `LlmModelModal.vue` 文件保留但失去引用入口——下一轮如需"更多模型"再加回;本轮不删文件。
|
||||
|
||||
## 验证
|
||||
|
||||
| 步骤 | 期望 |
|
||||
|---|---|
|
||||
| 1 | 启动 dev,深色主题下顶部 ChatHeader:左侧 `agent-avatar` 圆形(accent 背景)+ agent 名称;右侧依次:垃圾桶 icon、模型下拉、文件面板折叠 icon |
|
||||
| 2 | 切换会话,`agent-avatar` 文字随 `agentName` 首字变化 |
|
||||
| 3 | 文件面板默认展开;点击 sidebar-fold icon → 文件面板收起,icon 变为 sidebar-unfold;再点 → 展开 |
|
||||
| 4 | 切换模型下拉 → 立即生效;ChatHeader 模型 select 与后端 `listLlmModels` 一致 |
|
||||
| 5 | 点击垃圾桶 icon → 触发 `handleClear`,WebSocket 发送 `clear` 类型消息,会话消息清空 |
|
||||
| 6 | 搜索框聚焦:边框变为 accent 色,placeholder 颜色变浅;失焦恢复 |
|
||||
| 7 | 会话侧栏分组今天 / 昨天 / 更早 默认全部展开;点击任一分组标题可手动折叠 |
|
||||
| 8 | 浅色主题下所有上述功能保持一致(继承 `--fe-*`) |
|
||||
| 9 | WebSocket 流式消息、自动下拉、Token 轮询等不回归 |
|
||||
| 10 | 不做 git 推送;本地变更未提交 |
|
||||
|
||||
## 风险
|
||||
|
||||
- 删除 `handleTabChange` 时要确认 index.vue 模板上无 `@tab-change="..."` 残留;
|
||||
- 删除 `setActiveTab` expose 方法时确认 index.vue 内无 `chatHeaderRef.value?.setActiveTab(...)` 调用(`handleFilePanelClose` 中有,需要一并删除);
|
||||
- 移除 `LlmModelModal` 引用可能使该文件成为孤儿;保留即可,无外部影响;
|
||||
- `el-select` 与 `el-button` 共存于 `.header-right` 的 `gap: 8px` 样式需保持视觉留白与上一轮 `8px` 一致。
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 509 KiB After Width: | Height: | Size: 525 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 838 KiB |
|
After Width: | Height: | Size: 930 KiB |
@@ -1,40 +1,49 @@
|
||||
<template>
|
||||
<div class="chat-header">
|
||||
<div class="header-left">
|
||||
<div class="agent-avatar">{{ agentNameInitial }}</div>
|
||||
<span class="conversation-name">{{ agentName }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- 清空会话 -->
|
||||
<el-button @click="emit('clear')">
|
||||
清空会话
|
||||
<el-tooltip content="清空会话" placement="bottom" :show-after="300">
|
||||
<el-button class="icon-btn icon-btn--danger" link @click="emit('clear')">
|
||||
<el-icon><Brush /></el-icon>
|
||||
</el-button>
|
||||
<!-- 模型选择 -->
|
||||
<el-button @click="showModelModal = true">
|
||||
{{ currentModel?.name || currentModel?.modelName || '选择模型' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="activeTab === 'agent' ? 'primary' : 'default'"
|
||||
@click="handleTabClick('agent')"
|
||||
>
|
||||
智能体
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="activeTab === 'file' ? 'primary' : 'default'"
|
||||
@click="handleTabClick('file')"
|
||||
>
|
||||
文件
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 模型选择弹窗 -->
|
||||
<LlmModelModal v-model="showModelModal" @select="handleModelSelect" />
|
||||
<el-select
|
||||
class="model-select"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="handleSelectChange"
|
||||
popper-class="frontend-el-select-dropdown"
|
||||
placeholder="选择模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in modelOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-tooltip
|
||||
:content="filePanelVisible ? '收起文件面板' : '展开文件面板'"
|
||||
placement="bottom"
|
||||
:show-after="300"
|
||||
>
|
||||
<el-button class="icon-btn" link @click="handleToggleClick">
|
||||
<el-icon>
|
||||
<component :is="filePanelVisible ? Fold : Expand" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Cpu } from '@element-plus/icons-vue'
|
||||
import LlmModelModal from './LlmModelModal.vue'
|
||||
import { computed } from 'vue'
|
||||
import { Brush, Fold, Expand } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
agentName: {
|
||||
@@ -44,26 +53,36 @@ const props = defineProps({
|
||||
currentModel: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
modelOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
filePanelVisible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const activeTab = ref('agent')
|
||||
const showModelModal = ref(false)
|
||||
const emit = defineEmits(['clear', 'update:modelValue', 'toggle-file-panel'])
|
||||
|
||||
const emit = defineEmits(['tab-change', 'model-change', 'clear'])
|
||||
|
||||
function handleTabClick(tab) {
|
||||
activeTab.value = tab
|
||||
emit('tab-change', tab)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
setActiveTab: (tab) => {
|
||||
activeTab.value = tab
|
||||
}
|
||||
const agentNameInitial = computed(() => {
|
||||
const name = (props.agentName || '').trim()
|
||||
if (!name) return '?'
|
||||
// 中文取首字,英文取首字母
|
||||
return [...name][0]
|
||||
})
|
||||
function handleModelSelect(model) {
|
||||
emit('model-change', model)
|
||||
|
||||
function handleSelectChange(value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function handleToggleClick() {
|
||||
emit('toggle-file-panel')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -78,8 +97,25 @@ function handleModelSelect(model) {
|
||||
color: var(--fe-text-primary);
|
||||
|
||||
.header-left {
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 480px;
|
||||
|
||||
.agent-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--fe-accent);
|
||||
color: var(--fe-text-inverse);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
@@ -93,6 +129,43 @@ function handleModelSelect(model) {
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.icon-btn {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--fe-text-secondary);
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--fe-radius-md);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--fe-bg-hover);
|
||||
color: var(--fe-text-primary);
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
color: var(--fe-danger);
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-select {
|
||||
width: 180px;
|
||||
:deep(.el-select__wrapper) {
|
||||
background: var(--fe-bg-elevated);
|
||||
box-shadow: 0 0 0 1px var(--fe-border) inset;
|
||||
border-radius: var(--fe-radius-md);
|
||||
}
|
||||
:deep(.el-select__placeholder) { color: var(--fe-text-muted); }
|
||||
:deep(.el-select__selected-item) { color: var(--fe-text-primary); }
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 })
|
||||
@@ -445,7 +527,7 @@ defineExpose({ scrollToBottom, forceScrollToBottom })
|
||||
.chat-window {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
padding: 12px 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -491,7 +573,7 @@ defineExpose({ scrollToBottom, forceScrollToBottom })
|
||||
}
|
||||
|
||||
.message-group {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&.user-group {
|
||||
display: flex;
|
||||
@@ -501,7 +583,7 @@ defineExpose({ scrollToBottom, forceScrollToBottom })
|
||||
.message-bubble {
|
||||
background: var(--fe-bg-elevated);
|
||||
color: var(--fe-text-primary);
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
border-radius: 10px 10px 4px 10px;
|
||||
box-shadow: var(--fe-shadow-sm);
|
||||
max-width: 70%;
|
||||
|
||||
@@ -530,15 +612,16 @@ defineExpose({ scrollToBottom, forceScrollToBottom })
|
||||
.message-bubble {
|
||||
background: var(--fe-bg-elevated);
|
||||
color: var(--fe-text-primary);
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
border-radius: 10px 10px 10px 4px;
|
||||
box-shadow: var(--fe-shadow-sm);
|
||||
max-width: 70%;
|
||||
max-width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 10px 14px;
|
||||
padding: 9px 13px;
|
||||
line-height: 1.6;
|
||||
border-radius: 10px;
|
||||
|
||||
.message-text {
|
||||
margin: 0;
|
||||
@@ -584,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,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">
|
||||
@@ -24,12 +26,30 @@
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-search">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索会话"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<el-scrollbar class="sidebar-scrollbar">
|
||||
<div v-if="loading && sessionList.length === 0" class="loading-tip">
|
||||
加载中...
|
||||
</div>
|
||||
<template v-for="group in sessionGroups" :key="group.key">
|
||||
<template v-if="group.items.length > 0">
|
||||
<div class="group-header" @click="toggleGroup(group.key)">
|
||||
<span class="group-title">{{ group.label }}</span>
|
||||
<el-icon class="group-toggle">
|
||||
<ArrowDown v-if="expandedGroups.has(group.key)" />
|
||||
<ArrowRight v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div v-show="expandedGroups.has(group.key)" class="group-items">
|
||||
<div
|
||||
v-for="session in sessionList"
|
||||
v-for="session in group.items"
|
||||
:key="session.sessionId"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentSession?.sessionId === session.sessionId, 'has-new-message': session.hasNewMessage }"
|
||||
@@ -43,6 +63,9 @@
|
||||
</div>
|
||||
<div v-if="session.hasNewMessage" class="new-message-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<teleport to="body">
|
||||
@@ -67,21 +90,90 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { DArrowLeft, DArrowRight, Refresh, Plus, Delete, Loading } from '@element-plus/icons-vue'
|
||||
import { DArrowLeft, DArrowRight, Refresh, Plus, Delete, Loading, Search, ArrowDown, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { listChatSessions, deleteChatSession, renameChatSession } from '@/api/frontend'
|
||||
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)
|
||||
const loading = ref(false)
|
||||
const emit = defineEmits(['select', 'new-session', 'ready'])
|
||||
|
||||
// —— 搜索关键字(仅样式占位,本次不绑定行为) ——
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// —— 分组折叠状态:'today' | 'yesterday' | 'earlier' ——
|
||||
const expandedGroups = ref(new Set())
|
||||
|
||||
function parseTimestamp(t) {
|
||||
if (!t) return null
|
||||
if (typeof t === 'number' && String(t).length === 13) return t
|
||||
if (typeof t === 'number' || /^\d{14}$/.test(t)) {
|
||||
const s = String(t)
|
||||
return new Date(`${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}T${s.slice(8,10)}:${s.slice(10,12)}:${s.slice(12,14)}`).getTime()
|
||||
}
|
||||
if (typeof t === 'string' && t.includes(' ')) {
|
||||
const iso = t.replace(' ', 'T').replace(/\.(\d+)?$/, '')
|
||||
const ms = new Date(iso).getTime()
|
||||
return Number.isNaN(ms) ? null : ms
|
||||
}
|
||||
const ms = new Date(t).getTime()
|
||||
return Number.isNaN(ms) ? null : ms
|
||||
}
|
||||
|
||||
function groupOf(session) {
|
||||
const ts = parseTimestamp(session?.updateTime)
|
||||
if (ts == null) {
|
||||
console.warn('[ConversationSidebar] 无法解析会话时间', session)
|
||||
return 'earlier'
|
||||
}
|
||||
const now = new Date()
|
||||
const d = new Date(ts)
|
||||
const sameDay = (a, b) =>
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1)
|
||||
if (sameDay(d, now)) return 'today'
|
||||
if (sameDay(d, yesterday)) return 'yesterday'
|
||||
return 'earlier'
|
||||
}
|
||||
|
||||
const sessionGroups = computed(() => {
|
||||
const groups = { today: [], yesterday: [], earlier: [] }
|
||||
for (const s of sessionList.value) {
|
||||
groups[groupOf(s)].push(s)
|
||||
}
|
||||
return [
|
||||
{ key: 'today', label: '今天', items: groups.today },
|
||||
{ key: 'yesterday', label: '昨天', items: groups.yesterday },
|
||||
{ key: 'earlier', label: '更早', items: groups.earlier }
|
||||
]
|
||||
})
|
||||
|
||||
function toggleGroup(key) {
|
||||
const next = new Set(expandedGroups.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
expandedGroups.value = next
|
||||
}
|
||||
|
||||
function syncExpandedWithCurrent() {
|
||||
// 用户要求默认全部展开;用户可点击分组标题手动收/展
|
||||
expandedGroups.value = new Set(['today', 'yesterday', 'earlier'])
|
||||
}
|
||||
|
||||
// 响应式:viewport ≤ 900px 收缩,> 900px 展开
|
||||
let sidebarMediaQuery = null
|
||||
const sidebarBreakpointHandler = (e) => {
|
||||
@@ -148,6 +240,7 @@ function toggleSidebar() {
|
||||
function selectSession(session) {
|
||||
currentSession.value = session
|
||||
emit('select', session)
|
||||
syncExpandedWithCurrent()
|
||||
}
|
||||
|
||||
async function fetchSessions() {
|
||||
@@ -169,6 +262,7 @@ async function fetchSessions() {
|
||||
const remain = 300 - elapsed
|
||||
if (remain > 0) await new Promise(resolve => setTimeout(resolve, remain))
|
||||
loading.value = false
|
||||
syncExpandedWithCurrent()
|
||||
emit('ready')
|
||||
}
|
||||
}
|
||||
@@ -201,6 +295,7 @@ async function handleDeleteSession(session) {
|
||||
currentSession.value = null
|
||||
emit('select', null)
|
||||
}
|
||||
syncExpandedWithCurrent()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除会话失败:', error)
|
||||
@@ -310,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;
|
||||
@@ -352,6 +455,50 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
padding: 8px 12px 0;
|
||||
:deep(.el-input__wrapper) {
|
||||
background: var(--fe-bg-elevated);
|
||||
box-shadow: 0 0 0 1px var(--fe-border) inset;
|
||||
border-radius: var(--fe-radius-md);
|
||||
transition: box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--fe-accent) inset;
|
||||
background: var(--fe-bg-elevated);
|
||||
}
|
||||
:deep(.el-input__inner) {
|
||||
color: var(--fe-text-primary);
|
||||
&::placeholder { color: var(--fe-text-muted); }
|
||||
}
|
||||
:deep(.el-input__wrapper.is-focus .el-input__inner::placeholder) {
|
||||
color: var(--fe-text-secondary);
|
||||
}
|
||||
:deep(.el-input__prefix .el-icon) { color: var(--fe-text-muted); }
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fe-accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.group-toggle {
|
||||
color: var(--fe-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
&:hover .group-toggle { color: var(--fe-text-primary); }
|
||||
}
|
||||
|
||||
.group-items { padding: 0 8px; }
|
||||
|
||||
@keyframes sidebar-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -378,7 +525,7 @@ defineExpose({
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: all 0.2s;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--fe-radius-md);
|
||||
color: var(--fe-text-primary);
|
||||
font-size: 14px;
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -790,6 +790,13 @@ function handleNodeClick(data) {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
// 浅色主题下 el-scrollbar 滚动区背景设白
|
||||
:deep(.el-scrollbar__wrap) {
|
||||
body[data-fe-theme="light"] & {
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-wrapper,
|
||||
.empty-wrapper {
|
||||
padding:20px;
|
||||
|
||||
@@ -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,11 +11,13 @@
|
||||
<div class="main-content">
|
||||
<!-- 顶部栏 -->
|
||||
<ChatHeader
|
||||
ref="chatHeaderRef"
|
||||
:agent-name="currentConversation?.agentName || '请选择会话'"
|
||||
:agent-name="currentConversation?.agentName || headerName"
|
||||
:current-model="currentModel"
|
||||
@tab-change="handleTabChange"
|
||||
@model-change="handleModelChange"
|
||||
:model-options="modelSelectOptions"
|
||||
:model-value="currentModel?.modelName || currentModel?.name || ''"
|
||||
:file-panel-visible="filePanelVisible"
|
||||
@update:model-value="handleModelSelectChange"
|
||||
@toggle-file-panel="handleToggleFilePanel"
|
||||
@clear="handleClear"
|
||||
/>
|
||||
|
||||
@@ -183,14 +185,21 @@ const chatWindowRef = ref();
|
||||
const inputAreaRef = ref();
|
||||
const conversationSidebarRef = ref();
|
||||
const editorTextareaRef = ref();
|
||||
const chatHeaderRef = 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);
|
||||
const switchingConversation = ref(false);
|
||||
const filePanelVisible = ref(false);
|
||||
const filePanelVisible = ref(true);
|
||||
|
||||
// 智能体市场跳转带来的 agentId
|
||||
const agentId = ref(null);
|
||||
@@ -206,7 +215,6 @@ const activeWorkspacePath = computed(() => {
|
||||
// 模型相关
|
||||
const currentModel = ref(null);
|
||||
const modelList = ref([]);
|
||||
const showModelModal = ref(false);
|
||||
|
||||
// 文件预览相关
|
||||
const previewVisible = ref(false);
|
||||
@@ -307,6 +315,19 @@ async function loadAgentIfNeeded() {
|
||||
}
|
||||
}
|
||||
|
||||
const modelSelectOptions = computed(() => modelList.value.map(m => ({
|
||||
label: m.name || m.modelName || '',
|
||||
value: m.modelName || m.name || ''
|
||||
})))
|
||||
|
||||
function handleModelSelectChange(value) {
|
||||
const found = modelList.value.find(m => (m.modelName || m.name) === value)
|
||||
if (found) {
|
||||
currentModel.value = found
|
||||
ElMessage.success(`已选择模型: ${found.name || found.modelName}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化模型列表
|
||||
async function initModels() {
|
||||
try {
|
||||
@@ -431,7 +452,7 @@ function handleWsMessage(data) {
|
||||
currentAssistantMessage = {
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: '正在思考...',
|
||||
content: '正在思考',
|
||||
isThinking: true,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
@@ -458,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 {
|
||||
@@ -540,7 +561,7 @@ function handleWsMessage(data) {
|
||||
currentAssistantMessage = {
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: '正在思考...',
|
||||
content: '正在思考',
|
||||
isThinking: true,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
@@ -743,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;
|
||||
@@ -753,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);
|
||||
@@ -763,6 +792,8 @@ async function handleConversationSelect(session) {
|
||||
switchingConversation.value = false;
|
||||
// 切换会话后滚动到底部
|
||||
chatWindowRef.value?.scrollToBottom();
|
||||
// 进入会话时立即调用一次 tokenUsage(不阻塞消息加载)
|
||||
fetchTokenUsage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,24 +837,13 @@ function transformMessages(rawMessages) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleTabChange(tab) {
|
||||
if (tab === "agent") {
|
||||
filePanelVisible.value = false;
|
||||
previewVisible.value = false;
|
||||
} else if (tab === "file") {
|
||||
filePanelVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilePanelClose() {
|
||||
filePanelVisible.value = false;
|
||||
previewVisible.value = false;
|
||||
chatHeaderRef.value?.setActiveTab('agent');
|
||||
}
|
||||
|
||||
function handleModelChange(model) {
|
||||
currentModel.value = model;
|
||||
ElMessage.success(`已选择模型: ${model.name || model.modelName || model.llmName}`);
|
||||
function handleToggleFilePanel() {
|
||||
filePanelVisible.value = !filePanelVisible.value
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,34 +3,34 @@
|
||||
// --fe- 前缀保证不会与 admin 主题变量冲突(admin 模块未使用 --fe-*)。
|
||||
:root {
|
||||
// —— 基础色 ——
|
||||
--fe-bg-base: #1e1e1e;
|
||||
--fe-bg-elevated: #252526;
|
||||
--fe-bg-overlay: #2d2d30;
|
||||
--fe-bg-input: #3c3c3c;
|
||||
--fe-bg-hover: #2a2d2e;
|
||||
--fe-bg-base: #14171c;
|
||||
--fe-bg-elevated: #1c1f24;
|
||||
--fe-bg-overlay: #23272e;
|
||||
--fe-bg-input: #2a2e35;
|
||||
--fe-bg-hover: #1f2329;
|
||||
|
||||
// —— 边框 ——
|
||||
--fe-border: #3c3c3c;
|
||||
--fe-border-strong: #555555;
|
||||
--fe-border: #2c3138;
|
||||
--fe-border-strong: #4a505a;
|
||||
|
||||
// —— 文本 ——
|
||||
--fe-text-primary: #e6e6e6;
|
||||
--fe-text-secondary: #a0a0a0;
|
||||
--fe-text-muted: #6e6e6e;
|
||||
--fe-text-inverse: #1e1e1e;
|
||||
--fe-text-primary: #e4e6eb;
|
||||
--fe-text-secondary: #9aa0a6;
|
||||
--fe-text-muted: #6b7280;
|
||||
--fe-text-inverse: #14171c;
|
||||
|
||||
// —— 品牌色 ——
|
||||
--fe-accent: #4ec9b0;
|
||||
--fe-accent-hover: #5fd9c0;
|
||||
--fe-accent-soft: rgba(78, 201, 176, 0.15);
|
||||
--fe-danger: #f48771;
|
||||
--fe-danger-soft: rgba(244, 135, 113, 0.1);
|
||||
--fe-warning: #dcdcaa;
|
||||
--fe-info: #569cd6;
|
||||
--fe-info-soft: rgba(86, 156, 214, 0.1);
|
||||
--fe-info-border: rgba(86, 156, 214, 0.4);
|
||||
--fe-accent-border: rgba(78, 201, 176, 0.4);
|
||||
--fe-danger-border: rgba(244, 135, 113, 0.4);
|
||||
--fe-accent: #6c8cff;
|
||||
--fe-accent-hover: #8aa3ff;
|
||||
--fe-accent-soft: rgba(108, 140, 255, 0.15);
|
||||
--fe-danger: #f07178;
|
||||
--fe-danger-soft: rgba(240, 113, 120, 0.1);
|
||||
--fe-warning: #e0a050;
|
||||
--fe-info: #6cc4d6;
|
||||
--fe-info-soft: rgba(108, 196, 214, 0.1);
|
||||
--fe-info-border: rgba(108, 196, 214, 0.4);
|
||||
--fe-accent-border: rgba(108, 140, 255, 0.4);
|
||||
--fe-danger-border: rgba(240, 113, 120, 0.4);
|
||||
|
||||
// —— 布局尺寸 ——
|
||||
--fe-sidebar-width: 260px;
|
||||
@@ -41,9 +41,9 @@
|
||||
--fe-radius-sm: 4px;
|
||||
--fe-radius-md: 6px;
|
||||
--fe-radius-lg: 12px;
|
||||
--fe-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--fe-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
--fe-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--fe-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.55);
|
||||
--fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
// —— 浅色主题覆盖(通过 body[data-fe-theme="light"] 切换,由 settings.js 同步)——
|
||||
@@ -215,6 +215,34 @@ body[data-fe-theme="light"] {
|
||||
}
|
||||
}
|
||||
|
||||
// —— ElSelect 下拉面板(Teleport 到 body,需打 popper-class 标记)——
|
||||
// 在 ChatHeader 模型下拉里使用 popper-class="frontend-el-select-dropdown",
|
||||
// 让弹出的 .el-select-dropdown 继承 frontend 主题。
|
||||
.frontend-el-select-dropdown {
|
||||
--el-bg-color: var(--fe-bg-elevated);
|
||||
--el-bg-color-overlay: var(--fe-bg-overlay);
|
||||
--el-text-color-primary: var(--fe-text-primary);
|
||||
--el-text-color-regular: var(--fe-text-secondary);
|
||||
--el-text-color-secondary: var(--fe-text-muted);
|
||||
--el-text-color-placeholder: var(--fe-text-muted);
|
||||
--el-border-color: var(--fe-border);
|
||||
--el-border-color-light: var(--fe-border);
|
||||
--el-border-color-lighter: var(--fe-border);
|
||||
--el-border-color-extra-light: var(--fe-border);
|
||||
--el-fill-color-blank: var(--fe-bg-elevated);
|
||||
--el-fill-color-light: var(--fe-bg-hover);
|
||||
--el-fill-color-lighter: var(--fe-bg-hover);
|
||||
--el-color-primary: var(--fe-accent);
|
||||
--el-color-primary-light-3: var(--fe-accent-hover);
|
||||
--el-color-primary-light-5: var(--fe-accent-soft);
|
||||
--el-color-primary-light-7: var(--fe-accent-soft);
|
||||
--el-color-primary-light-9: var(--fe-accent-soft);
|
||||
--el-color-danger: var(--fe-danger);
|
||||
|
||||
border: 1px solid var(--fe-border);
|
||||
box-shadow: var(--fe-shadow-md);
|
||||
}
|
||||
|
||||
// —— 全局右键菜单(用于会话列表 / 文件面板等,通过 Teleport 渲染到 body)——
|
||||
// --fe-* 变量在 :root 全局可继承,菜单元素样式不受所在 DOM 位置影响。
|
||||
.context-menu {
|
||||
|
||||