refactor(ux): simplify interactions and humanize UI with Jobs-inspired review

This commit is contained in:
matevip 2026-04-09 20:56:50 +08:00
parent 4cd373dcdd
commit 1c2acfd2e9
11 changed files with 916 additions and 225 deletions

View File

@ -261,6 +261,19 @@ public class ConversationService {
messageMapper.updateById(message);
}
/**
* 重命名会话
*/
@Transactional
public void renameConversation(String conversationId, String title) {
ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv != null) {
conv.setTitle(title);
conversationMapper.updateById(conv);
}
}
/**
* 更新会话的流状态running / idle
*/

View File

@ -68,6 +68,24 @@ public class ConversationController {
return R.ok();
}
/**
* 重命名会话
*/
@Operation(summary = "重命名会话")
@PutMapping("/{conversationId}/title")
public R<Void> rename(@PathVariable String conversationId, @RequestBody Map<String, String> body, Authentication auth) {
String username = auth != null ? auth.getName() : "anonymous";
if (!conversationService.isConversationOwner(conversationId, username)) {
return R.fail("无权操作该会话");
}
String title = body.getOrDefault("title", "").trim();
if (title.isEmpty() || title.length() > 100) {
return R.fail("标题不合法");
}
conversationService.renameConversation(conversationId, title);
return R.ok();
}
/**
* 清空会话消息保留会话记录
*/

View File

@ -136,6 +136,8 @@ export const conversationApi = {
http.delete(`/conversations/${conversationId}`),
clearMessages: (conversationId: string) =>
http.delete(`/conversations/${conversationId}/messages`),
rename: (conversationId: string, title: string) =>
http.put(`/conversations/${conversationId}/title`, { title }),
}
// ==================== Skill ====================

View File

@ -1,65 +1,67 @@
<template>
<div class="workspace-switcher" :class="{ collapsed }">
<el-dropdown
v-if="workspaces.length > 0"
trigger="click"
:teleported="true"
@command="onSwitch"
<button
class="ws-trigger"
:title="collapsed ? currentLabel : ''"
@click="open = !open"
>
<button class="ws-trigger" :title="collapsed ? currentLabel : ''">
<span class="ws-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<span class="ws-trigger__icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
</span>
<template v-if="!collapsed">
<span class="ws-trigger__name">{{ currentLabel }}</span>
<svg class="ws-trigger__arrow" :class="{ open }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</template>
</button>
<Transition name="fade">
<div v-if="open" class="ws-backdrop" @click="open = false"></div>
</Transition>
<Transition name="ws-dropdown">
<div v-if="open" class="ws-dropdown">
<div
v-for="ws in workspaces"
:key="ws.id"
class="ws-item"
:class="{ active: ws.id === currentWorkspaceId }"
@click="onSelect(ws.id)"
>
<svg class="ws-item__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
</span>
<span v-if="!collapsed" class="ws-name">{{ currentLabel }}</span>
<svg v-if="!collapsed" class="ws-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="ws in workspaces"
:key="ws.id"
:command="ws.id"
:class="{ 'is-active': ws.id === currentWorkspaceId }"
>
<span class="ws-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
</span>
{{ ws.name }}
</el-dropdown-item>
<el-dropdown-item divided command="__manage__">
<span class="ws-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</span>
Manage Workspaces
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="ws-item__name">{{ ws.name }}</span>
<svg v-if="ws.id === currentWorkspaceId" class="ws-item__check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div class="ws-divider"></div>
<div class="ws-item ws-item--manage" @click="onManage">
<svg class="ws-item__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="ws-item__name">{{ t('common.manageWorkspaces') }}</span>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
defineProps<{
collapsed?: boolean
}>()
const { t } = useI18n()
const store = useWorkspaceStore()
const router = useRouter()
const open = ref(false)
const workspaces = computed(() => store.workspaces)
const currentWorkspaceId = computed(() => store.currentWorkspaceId)
const currentLabel = computed(() => store.currentWorkspace?.name || 'Workspace')
@ -68,45 +70,51 @@ onMounted(() => {
store.fetchWorkspaces()
})
function onSwitch(id: number | string) {
if (id === '__manage__') {
router.push('/settings/workspaces')
return
function onSelect(id: number) {
open.value = false
if (id !== currentWorkspaceId.value) {
store.switchWorkspace(id)
}
store.switchWorkspace(id as number)
}
function onManage() {
open.value = false
router.push('/settings/workspaces')
}
</script>
<style scoped>
.workspace-switcher {
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter, #ebeef5);
position: relative;
}
.workspace-switcher.collapsed {
padding: 8px 4px;
padding: 8px 6px;
display: flex;
justify-content: center;
}
/* Trigger */
.ws-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border: none;
border-radius: 6px;
background: var(--el-fill-color-light, #f5f7fa);
color: var(--el-text-color-primary, #303133);
padding: 7px 10px;
border: 1px solid var(--mc-sidebar-border, var(--mc-border-light));
border-radius: 12px;
background: var(--mc-sidebar-hover, rgba(0,0,0,0.04));
color: var(--mc-sidebar-text, var(--mc-text-primary));
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.2s;
font-weight: 600;
transition: all 0.15s;
}
.ws-trigger:hover {
background: var(--el-fill-color, #f0f2f5);
background: var(--mc-sidebar-active, rgba(0,0,0,0.06));
border-color: var(--mc-primary, #d96d46);
}
.collapsed .ws-trigger {
@ -114,36 +122,129 @@ function onSwitch(id: number | string) {
height: 36px;
padding: 0;
justify-content: center;
border-radius: 10px;
}
.ws-icon {
.ws-trigger__icon {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.7;
color: var(--mc-accent, var(--mc-primary));
}
.ws-name {
.ws-trigger__name {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: -0.01em;
}
.ws-chevron {
.ws-trigger__arrow {
flex-shrink: 0;
color: var(--mc-sidebar-text, var(--mc-text-tertiary));
opacity: 0.5;
transition: transform 0.2s;
}
.ws-trigger__arrow.open {
transform: rotate(180deg);
}
/* Backdrop */
.ws-backdrop {
position: fixed;
inset: 0;
z-index: 99;
}
/* Dropdown */
.ws-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 12px;
right: 12px;
z-index: 100;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
padding: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
max-height: 280px;
overflow-y: auto;
}
.collapsed .ws-dropdown {
left: 6px;
right: auto;
min-width: 200px;
}
.ws-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 0.12s;
font-size: 13px;
color: var(--mc-text-primary);
}
.ws-item:hover {
background: var(--mc-bg-sunken);
}
.ws-item.active {
background: var(--mc-primary-bg);
color: var(--mc-primary);
font-weight: 600;
}
.ws-item__icon {
flex-shrink: 0;
opacity: 0.5;
}
.ws-menu-icon {
display: inline-flex;
margin-right: 6px;
opacity: 0.6;
.ws-item.active .ws-item__icon {
opacity: 1;
color: var(--mc-primary);
}
:deep(.is-active) {
color: var(--el-color-primary);
font-weight: 600;
.ws-item__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-item__check {
flex-shrink: 0;
color: var(--mc-primary);
}
.ws-divider {
height: 1px;
background: var(--mc-border-light);
margin: 4px 8px;
}
.ws-item--manage {
color: var(--mc-text-secondary);
}
.ws-item--manage:hover {
color: var(--mc-text-primary);
}
/* Transitions */
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.ws-dropdown-enter-active { transition: all 0.15s ease-out; }
.ws-dropdown-leave-active { transition: all 0.1s ease-in; }
.ws-dropdown-enter-from { opacity: 0; transform: translateY(-6px) scale(0.97); }
.ws-dropdown-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>

View File

@ -22,6 +22,7 @@ export default {
search: 'Search',
expandSidebar: 'Expand sidebar',
collapseSidebar: 'Collapse sidebar',
manageWorkspaces: 'Manage Workspaces',
show: 'Show',
hide: 'Hide',
on: 'On',
@ -115,6 +116,7 @@ export default {
loadConversationsFailed: 'Failed to load conversations',
loadMessagesFailed: 'Failed to load messages',
deleteConversationFailed: 'Failed to delete conversation',
deleteConfirm: 'Delete this conversation? This cannot be undone.',
switchModelFailed: 'Failed to switch model',
uploadFailed: 'File upload failed',
dropToUpload: 'Drop files or folders here',
@ -597,6 +599,10 @@ export default {
planExecute: 'Plan-Execute',
enabled: 'Enabled',
disabled: 'Disabled',
basic: 'Basic',
skills: 'Skills',
tools: 'Tools',
context: 'Context',
},
columns: {
name: 'Name',
@ -656,6 +662,14 @@ export default {
toggleFailed: 'Failed to toggle agent status',
toggleSuccess: 'Status updated',
},
binding: {
skillsHint: 'Select skills this agent can use. Leave empty to use all enabled skills.',
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
noSkills: 'No skills available',
noTools: 'No tools available',
contextHint: 'Manage context files (e.g. AGENT.md) that define this agent\'s behavior, knowledge, and instructions.',
goToContext: 'Edit Context Files',
},
},
security: {
title: 'Security',
@ -727,6 +741,7 @@ export default {
workspaces: {
title: 'Workspaces',
desc: 'Manage workspaces for your organization.',
manage: 'Manage Workspaces',
newWorkspace: 'New Workspace',
loading: 'Loading workspaces...',
noWorkspaces: 'No workspaces found.',
@ -1527,8 +1542,8 @@ export default {
periodDesc: 'A sharper view of how your system behaves across short, medium, and monthly horizons.',
runsDesc: 'Execution history with timing, cost, and outcome at a glance.',
trend: {
title: '7-Day Trend',
subtitle: 'Messages and token consumption over the past week.',
title: 'Your Week',
subtitle: 'How often you and your AI have been working together.',
},
periodComparison: 'Period Comparison',
periods: {

View File

@ -22,6 +22,7 @@ export default {
search: '搜索',
expandSidebar: '展开侧边栏',
collapseSidebar: '折叠侧边栏',
manageWorkspaces: '管理工作区',
show: '显示',
hide: '隐藏',
on: '开启',
@ -115,6 +116,7 @@ export default {
loadConversationsFailed: '加载会话列表失败',
loadMessagesFailed: '加载消息记录失败',
deleteConversationFailed: '删除会话失败',
deleteConfirm: '确定删除这个会话?此操作不可恢复。',
switchModelFailed: '切换模型失败',
uploadFailed: '文件上传失败',
dropToUpload: '拖放文件或文件夹到此处',
@ -597,6 +599,10 @@ export default {
planExecute: 'Plan-Execute',
enabled: '已启用',
disabled: '已停用',
basic: '基本信息',
skills: '技能',
tools: '工具',
context: '上下文',
},
columns: {
name: '名称',
@ -656,6 +662,14 @@ export default {
toggleFailed: '切换状态失败',
toggleSuccess: '状态已更新',
},
binding: {
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
noSkills: '暂无可用技能',
noTools: '暂无可用工具',
contextHint: '管理此智能体的上下文文件(如 AGENT.md定义智能体的行为、知识和指令。',
goToContext: '前往编辑上下文',
},
},
security: {
title: '安全管理',
@ -727,6 +741,7 @@ export default {
workspaces: {
title: '工作区管理',
desc: '管理组织的工作区。',
manage: '管理工作区',
newWorkspace: '新建工作区',
loading: '加载中...',
noWorkspaces: '暂无工作区。',
@ -1537,8 +1552,8 @@ export default {
periodDesc: '从日、周、月三个维度观察系统运行状况。',
runsDesc: '定时任务执行记录,包含耗时、消耗和结果。',
trend: {
title: '7 天趋势',
subtitle: '过去一周的消息量和 Token 消耗趋势。',
title: '本周活跃度',
subtitle: '你和 AI 的互动频率。',
},
periodComparison: '周期对比',
periods: {

View File

@ -207,6 +207,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { agentApi, agentContextApi } from '@/api/index'
@ -262,8 +263,15 @@ const sortedFiles = computed(() => {
const renderedMarkdown = computed(() => renderMarkdown(fileContent.value || ''))
const route = useRoute()
onMounted(async () => {
await loadAgents()
// Hydrate from query param (e.g. from Agents page "Context" tab link)
const qAgentId = route.query.agentId
if (qAgentId && agents.value.some(a => String(a.id) === String(qAgentId))) {
selectedAgentId.value = qAgentId as string
}
})
watch(selectedAgentId, () => {

View File

@ -33,50 +33,39 @@
</div>
</div>
<!-- Agent table -->
<div class="table-wrap mc-surface-card" v-if="filteredAgents.length > 0">
<table class="agent-table">
<thead>
<tr>
<th class="col-name">{{ t('agents.columns.name') }}</th>
<th class="col-type">{{ t('agents.columns.agentType') }}</th>
<th class="col-tags">{{ t('agents.columns.tags') }}</th>
<th class="col-status">{{ t('agents.columns.enabled') }}</th>
<th class="col-time">{{ t('agents.columns.updateTime') }}</th>
<th class="col-actions">{{ t('agents.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="agent in filteredAgents" :key="agent.id" :class="{ 'row-disabled': !agent.enabled }">
<td class="col-name">
<div class="agent-name-cell">
<span class="agent-icon">{{ agent.icon || '🤖' }}</span>
<div class="agent-name-info">
<span class="agent-name">{{ agent.name }}</span>
<span class="agent-desc">{{ agent.description || t('agents.messages.noDescription') }}</span>
</div>
</div>
</td>
<td class="col-type">
<span class="tag type-tag">{{ agent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
</td>
<td class="col-tags">
<div class="tags-cell" v-if="agent.tags">
<span v-for="tag in parseTags(agent.tags)" :key="tag" class="tag tag-item">{{ tag }}</span>
</div>
<span v-else class="text-muted">-</span>
</td>
<td class="col-status">
<label class="toggle-switch">
<!-- Agent card grid -->
<div class="agent-grid" v-if="filteredAgents.length > 0">
<div
v-for="agent in filteredAgents"
:key="agent.id"
class="agent-card mc-surface-card"
:class="{ 'agent-card--disabled': !agent.enabled }"
>
<div class="agent-card__header">
<span class="agent-card__icon">{{ agent.icon || '🤖' }}</span>
<label class="toggle-switch toggle-switch--sm">
<input type="checkbox" :checked="agent.enabled" @change="toggleAgent(agent)" />
<span class="toggle-slider"></span>
</label>
</td>
<td class="col-time">
</div>
<div class="agent-card__body">
<h3 class="agent-card__name">{{ agent.name }}</h3>
<p class="agent-card__desc">{{ agent.description || t('agents.messages.noDescription') }}</p>
</div>
<div class="agent-card__meta">
<span class="tag type-tag">{{ agent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
<div class="tags-cell" v-if="agent.tags">
<span v-for="tag in parseTags(agent.tags)" :key="tag" class="tag tag-item">{{ tag }}</span>
</div>
</div>
<div class="agent-card__footer">
<span class="time-label">{{ formatTime(agent.updateTime) }}</span>
</td>
<td class="col-actions">
<div class="action-btns">
<div class="agent-card__actions">
<button class="action-btn" :title="t('agents.tabs.context')" @click="goToAgentContextFor(agent)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</button>
<button class="action-btn" :title="t('agents.actions.edit')" @click="openEditModal(agent)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
@ -90,10 +79,8 @@
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Empty state -->
@ -221,8 +208,8 @@
<!-- Skills Tab -->
<div v-if="modalTab === 'skills'" class="binding-tab">
<p class="binding-hint">{{ t('agents.binding.skillsHint', 'Select skills this agent can use. Leave empty to use all enabled skills.') }}</p>
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills', 'No skills available') }}</div>
<p class="binding-hint">{{ t('agents.binding.skillsHint') }}</p>
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div>
<div v-else class="binding-list">
<label
v-for="skill in availableSkills"
@ -243,8 +230,8 @@
<!-- Tools Tab -->
<div v-if="modalTab === 'tools'" class="binding-tab">
<p class="binding-hint">{{ t('agents.binding.toolsHint', 'Select tools this agent can use. Leave empty to use all enabled tools.') }}</p>
<div v-if="availableTools.length === 0" class="binding-empty">{{ t('agents.binding.noTools', 'No tools available') }}</div>
<p class="binding-hint">{{ t('agents.binding.toolsHint') }}</p>
<div v-if="availableTools.length === 0" class="binding-empty">{{ t('agents.binding.noTools') }}</div>
<div v-else class="binding-list">
<label
v-for="tool in availableTools"
@ -276,11 +263,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { agentApi, agentBindingApi, skillApi, toolApi, templateApi } from '@/api/index'
import type { Agent } from '@/types/index'
const router = useRouter()
const { t } = useI18n()
const agents = ref<Agent[]>([])
const searchText = ref('')
@ -485,6 +474,16 @@ async function deleteAgent(agent: Agent) {
}
}
function goToAgentContext() {
const agentId = editingAgent.value?.id
closeModal()
router.push({ path: '/settings/agent-context', query: agentId ? { agentId: String(agentId) } : {} })
}
function goToAgentContextFor(agent: Agent) {
router.push({ path: '/settings/agent-context', query: { agentId: String(agent.id) } })
}
async function toggleAgent(agent: Agent) {
try {
await agentApi.update(agent.id, { ...agent, enabled: !agent.enabled })
@ -515,27 +514,122 @@ async function toggleAgent(agent: Agent) {
.filter-tab:hover { background: var(--mc-bg-sunken); }
.filter-tab.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-primary); font-weight: 500; }
/* Table */
.table-wrap { overflow-x: auto; }
.agent-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.agent-table th { padding: 14px 16px; text-align: left; font-weight: 700; font-size: 12px; color: var(--mc-text-secondary); background: var(--mc-bg-muted); border-bottom: 1px solid var(--mc-border); white-space: nowrap; text-transform: uppercase; letter-spacing: 0.08em; }
.agent-table td { padding: 12px 16px; border-bottom: 1px solid var(--mc-border-light); vertical-align: middle; }
.agent-table tbody tr:last-child td { border-bottom: none; }
.agent-table tbody tr:hover { background: var(--mc-bg-muted); }
.agent-table tbody tr.row-disabled { opacity: 0.55; }
/* Agent Card Grid */
.agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.col-name { min-width: 200px; }
.col-type { min-width: 110px; }
.col-tags { min-width: 120px; }
.col-status { min-width: 80px; }
.col-time { min-width: 140px; }
.col-actions { min-width: 90px; }
.agent-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
transition: all 0.15s;
cursor: default;
}
.agent-name-cell { display: flex; align-items: center; gap: 10px; }
.agent-icon { font-size: 20px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--mc-primary-bg); border-radius: 8px; flex-shrink: 0; }
.agent-name-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.agent-name { font-weight: 600; color: var(--mc-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.agent-desc { font-size: 12px; color: var(--mc-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
.agent-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.agent-card--disabled {
opacity: 0.55;
}
.agent-card__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.agent-card__icon {
font-size: 36px;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: var(--mc-primary-bg);
border-radius: 14px;
}
.agent-card__body {
flex: 1;
min-height: 0;
}
.agent-card__name {
font-size: 16px;
font-weight: 700;
color: var(--mc-text-primary);
margin: 0 0 4px;
letter-spacing: -0.02em;
}
.agent-card__desc {
font-size: 13px;
color: var(--mc-text-tertiary);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
}
.agent-card__meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.agent-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 10px;
border-top: 1px solid var(--mc-border-light);
}
.agent-card__actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.agent-card:hover .agent-card__actions {
opacity: 1;
}
.toggle-switch--sm { width: 32px; height: 18px; }
.toggle-switch--sm .toggle-slider::before { width: 12px; height: 12px; }
.toggle-switch--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
/* Context link card */
.context-link-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px;
cursor: pointer;
border: 1px solid var(--mc-border);
border-radius: 12px;
transition: all 0.15s;
}
.context-link-card:hover {
border-color: var(--mc-primary);
background: var(--mc-primary-bg);
}
.context-link-card__icon { font-size: 28px; }
.context-link-card__info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.context-link-card__title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); }
.context-link-card__desc { font-size: 12px; color: var(--mc-text-tertiary); }
.context-link-card__arrow { color: var(--mc-text-tertiary); flex-shrink: 0; }
.tag { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.type-tag { background: var(--mc-primary-bg); color: var(--mc-primary); }

View File

@ -14,18 +14,41 @@
<div class="panel-kicker">{{ $t('nav.chat') }}</div>
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
</div>
<button class="new-chat-btn" @click="newConversation" :title="$t('chat.newChat')">
<button class="new-chat-btn" @click="newConversation" :title="`${$t('chat.newChat')} (⌘N)`">
<el-icon><Plus /></el-icon>
</button>
</div>
<div class="agent-selector">
<select v-model="selectedAgentId" class="agent-select" @change="onAgentChange">
<option v-if="agents.length === 0" value="">{{ $t('chat.loadingAgents') }}</option>
<option v-for="agent in agents" :key="agent.id" :value="agent.id">
{{ agent.icon || '🤖' }} {{ agent.name }}
</option>
</select>
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="`${$t('chat.selectAgent')} (⌘K)`">
<span class="agent-select-trigger__icon">{{ currentAgent?.icon || '🤖' }}</span>
<span class="agent-select-trigger__name">{{ currentAgent?.name || $t('chat.selectAgent') }}</span>
<svg class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<Transition name="fade">
<div v-if="agentDropdownOpen" class="agent-dropdown-backdrop" @click="agentDropdownOpen = false"></div>
</Transition>
<Transition name="agent-dropdown">
<div v-if="agentDropdownOpen" class="agent-dropdown">
<div
v-for="agent in agents"
:key="agent.id"
class="agent-dropdown-item"
:class="{ active: String(agent.id) === String(selectedAgentId) }"
@click="selectAgent(agent)"
>
<span class="agent-dropdown-item__icon">{{ agent.icon || '🤖' }}</span>
<div class="agent-dropdown-item__info">
<span class="agent-dropdown-item__name">{{ agent.name }}</span>
<span class="agent-dropdown-item__desc">{{ agent.description || agent.agentType }}</span>
</div>
<span v-if="String(agent.id) === String(selectedAgentId)" class="agent-dropdown-item__check">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
</span>
</div>
<div v-if="agents.length === 0" class="agent-dropdown-empty">{{ $t('chat.loadingAgents') }}</div>
</div>
</Transition>
</div>
<div class="conversation-list">
@ -42,14 +65,24 @@
<img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" />
</div>
<div class="conv-info">
<div class="conv-title">{{ conv.title }}</div>
<input
v-if="renamingConvId === conv.conversationId"
v-model="renameText"
class="conv-title-input"
@keydown.enter="confirmRename(conv)"
@keydown.escape="cancelRename"
@blur="confirmRename(conv)"
@click.stop
ref="renameInputRef"
/>
<div v-else class="conv-title" @dblclick.stop="startRename(conv)">{{ conv.title }}</div>
<div class="conv-meta">
<span>{{ $t('chat.messages', { count: conv.messageCount }) }}</span>
<span class="conv-dot">·</span>
<span>{{ formatConversationTime(conv.lastActiveTime) }}</span>
</div>
</div>
<button class="conv-delete" @click.stop="deleteConversation(conv.conversationId)" :title="$t('common.delete')">
<button class="conv-delete" @click.stop="confirmDeleteConversation(conv.conversationId)" :title="$t('common.delete')">
<el-icon><Delete /></el-icon>
</button>
</div>
@ -97,23 +130,57 @@
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
</div>
<div class="chat-header-right">
<select
v-if="eligibleModels.length > 0"
:value="activeModelValue"
class="model-select"
:disabled="modelSaving"
@change="onModelChange"
>
<option v-for="item in eligibleModels" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
<!-- Model selector -->
<div v-if="eligibleModels.length > 0" class="model-selector-wrap">
<button class="model-select-trigger" :disabled="modelSaving" @click="modelDropdownOpen = !modelDropdownOpen">
<span class="model-select-trigger__name">{{ activeModelLabel || $t('chat.configModel') }}</span>
<svg class="model-select-trigger__arrow" :class="{ open: modelDropdownOpen }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<Transition name="fade">
<div v-if="modelDropdownOpen" class="model-dropdown-backdrop" @click="modelDropdownOpen = false"></div>
</Transition>
<Transition name="agent-dropdown">
<div v-if="modelDropdownOpen" class="model-dropdown">
<div
v-for="item in eligibleModels"
:key="item.value"
class="model-dropdown-item"
:class="{ active: item.value === activeModelValue }"
@click="selectModel(item.value)"
>
<span class="model-dropdown-item__name">{{ item.label }}</span>
<span v-if="item.value === activeModelValue" class="model-dropdown-item__check">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
</span>
</div>
</div>
</Transition>
</div>
<button v-else class="header-btn" @click="goToModelSettings" :title="$t('chat.configModel')">
<el-icon><Setting /></el-icon>
</button>
<button class="header-btn" @click="clearMessages" :title="$t('chat.clearMessages')">
<el-icon><Delete /></el-icon>
</button>
<!-- Overflow menu -->
<div class="header-overflow-wrap">
<button class="header-btn" @click="headerMenuOpen = !headerMenuOpen" :title="$t('common.more') || 'More'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Transition name="fade">
<div v-if="headerMenuOpen" class="header-menu-backdrop" @click="headerMenuOpen = false"></div>
</Transition>
<Transition name="agent-dropdown">
<div v-if="headerMenuOpen" class="header-menu">
<button class="header-menu-item" @click="headerMenuOpen = false; goToModelSettings()">
<el-icon><Setting /></el-icon>
<span>{{ $t('chat.configModel') }}</span>
</button>
<div class="header-menu-divider"></div>
<button class="header-menu-item header-menu-item--danger" @click="handleClearMessages">
<el-icon><Delete /></el-icon>
<span>{{ $t('chat.clearMessages') }}</span>
</button>
</div>
</Transition>
</div>
</div>
</div>
@ -162,7 +229,7 @@
:loading="isGenerating && !hasPendingApproval"
:disabled="showModelPrompt || !currentAgent"
:placeholder="$t('chat.messagePlaceholder')"
:hint="`MateClaw · ${currentRuntimeModel}` + (currentConversationId ? ` · Session · ${currentConversationId.slice(0, 8)}...` : '')"
:hint="currentRuntimeModel"
:attachments="pendingAttachments"
:uploading="uploadingAttachment"
:max-length="10240"
@ -195,10 +262,10 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
import { conversationApi, agentApi, modelApi, chatApi } from '@/api/index'
import { channelIconUrl } from '@/utils/channelSource'
@ -227,12 +294,30 @@ function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
}
// ============ ============
const suggestions = computed(() => [
t('chat.suggestionIntro'),
t('chat.suggestionPoem'),
t('chat.suggestionCode'),
t('chat.suggestionWeather'),
])
const suggestions = computed(() => {
const agent = currentAgent.value
// If agent has custom suggestions (stored as newline-separated string in description etc.)
const agentSuggestions = (agent as any)?.suggestions as string | undefined
if (agentSuggestions) {
const parsed = agentSuggestions.split('\n').filter(Boolean).slice(0, 4)
if (parsed.length) return parsed
}
// Agent-type-aware defaults
if (agent?.agentType === 'plan_execute') {
return [
t('chat.suggestionPlan1', '帮我制定一个完整的项目计划'),
t('chat.suggestionPlan2', '分步骤帮我完成一个复杂任务'),
t('chat.suggestionIntro'),
t('chat.suggestionWeather'),
]
}
return [
t('chat.suggestionIntro'),
t('chat.suggestionPoem'),
t('chat.suggestionCode'),
t('chat.suggestionWeather'),
]
})
// ============ ============
const router = useRouter()
@ -252,6 +337,80 @@ const activeModels = ref<ActiveModelsInfo | null>(null)
const pendingAttachments = ref<ChatAttachment[]>([])
const uploadingAttachment = ref(false)
// Dropdowns & menus
const agentDropdownOpen = ref(false)
const modelDropdownOpen = ref(false)
const headerMenuOpen = ref(false)
function selectAgent(agent: Agent) {
agentDropdownOpen.value = false
if (String(agent.id) !== String(selectedAgentId.value)) {
selectedAgentId.value = agent.id
newConversation()
}
}
async function selectModel(value: string) {
modelDropdownOpen.value = false
const [providerId, model] = value.split('::')
if (!providerId || !model) return
modelSaving.value = true
try {
const res: any = await modelApi.setActive({ providerId, model })
activeModels.value = res.data || { activeLlm: { providerId, model } }
await loadModelState()
} catch (e) {
ElMessage.error(t('chat.switchModelFailed'))
} finally {
modelSaving.value = false
}
}
function handleClearMessages() {
headerMenuOpen.value = false
clearMessages()
}
// Conversation rename
const renamingConvId = ref('')
const renameText = ref('')
const renameInputRef = ref<HTMLInputElement | null>(null)
function startRename(conv: Conversation) {
renamingConvId.value = conv.conversationId
renameText.value = conv.title || ''
nextTick(() => {
renameInputRef.value?.focus()
renameInputRef.value?.select()
})
}
async function confirmRename(conv: Conversation) {
const newTitle = renameText.value.trim()
renamingConvId.value = ''
if (!newTitle || newTitle === conv.title) return
conv.title = newTitle
try {
await conversationApi.rename(conv.conversationId, newTitle)
} catch {
// revert on fail reload
await loadConversations()
}
}
function cancelRename() {
renamingConvId.value = ''
}
// Delete with confirmation
function confirmDeleteConversation(conversationId: string) {
ElMessageBox.confirm(
t('chat.deleteConfirm') || 'Delete this conversation?',
t('common.confirm'),
{ type: 'warning', confirmButtonText: t('common.confirm'), cancelButtonText: t('common.cancel') }
).then(() => deleteConversation(conversationId)).catch(() => {})
}
//
const isDragging = ref(false)
let dragCounter = 0
@ -450,6 +609,12 @@ const activeModelValue = computed(() => {
return providerId && model ? `${providerId}::${model}` : ''
})
const activeModelLabel = computed(() => {
if (!activeModelValue.value) return ''
const match = eligibleModels.value.find(m => m.value === activeModelValue.value)
return match?.label || ''
})
const activeProvider = computed(() => {
const providerId = activeModels.value?.activeLlm?.providerId
return providerId ? providers.value.find((provider) => provider.id === providerId) || null : null
@ -488,7 +653,21 @@ const eligibleModels = computed(() => {
})
// ============ ============
function handleKeyboardShortcuts(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey
if (mod && e.key === 'n') {
e.preventDefault()
newConversation()
chatInputRef.value?.focus?.()
}
if (mod && e.key === 'k') {
e.preventDefault()
agentDropdownOpen.value = !agentDropdownOpen.value
}
}
onMounted(async () => {
document.addEventListener('keydown', handleKeyboardShortcuts)
document.addEventListener('click', handleCodeCopy)
startECharts()
mobileQuery = window.matchMedia('(max-width: 768px)')
@ -499,6 +678,7 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeyboardShortcuts)
document.removeEventListener('click', handleCodeCopy)
disposeECharts()
mobileQuery?.removeEventListener('change', handleMobileChange)
@ -701,26 +881,9 @@ async function clearMessages() {
}
}
function onAgentChange() {
newConversation()
}
// onAgentChange removed replaced by selectAgent()
async function onModelChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
const [providerId, model] = value.split('::')
if (!providerId || !model) return
modelSaving.value = true
try {
const res: any = await modelApi.setActive({ providerId, model })
activeModels.value = res.data || { activeLlm: { providerId, model } }
await loadModelState()
} catch (e) {
ElMessage.error(t('chat.switchModelFailed'))
} finally {
modelSaving.value = false
}
}
// onModelChange removed replaced by selectModel()
function goToModelSettings() {
router.push('/settings/models')
@ -1219,11 +1382,15 @@ function handleCodeCopy(e: MouseEvent) {
.agent-selector {
padding: 10px 12px 12px;
border-bottom: 1px solid var(--mc-border-light);
position: relative;
}
.agent-select {
.agent-select-trigger {
width: 100%;
padding: 9px 12px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--mc-border);
border-radius: 12px;
font-size: 13px;
@ -1231,11 +1398,132 @@ function handleCodeCopy(e: MouseEvent) {
background: var(--mc-bg-sunken);
cursor: pointer;
outline: none;
transition: all 0.15s;
}
.agent-select:focus {
.agent-select-trigger:hover {
border-color: var(--mc-primary);
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1);
background: var(--mc-bg-elevated);
}
.agent-select-trigger__icon {
font-size: 18px;
line-height: 1;
}
.agent-select-trigger__name {
flex: 1;
text-align: left;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-select-trigger__arrow {
flex-shrink: 0;
color: var(--mc-text-tertiary);
transition: transform 0.2s;
}
.agent-select-trigger__arrow.open {
transform: rotate(180deg);
}
.agent-dropdown-backdrop,
.model-dropdown-backdrop,
.header-menu-backdrop {
position: fixed;
inset: 0;
z-index: 99;
}
.agent-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 12px;
right: 12px;
z-index: 100;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
padding: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
max-height: 320px;
overflow-y: auto;
}
.agent-dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 0.12s;
}
.agent-dropdown-item:hover {
background: var(--mc-bg-sunken);
}
.agent-dropdown-item.active {
background: var(--mc-primary-bg);
}
.agent-dropdown-item__icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
}
.agent-dropdown-item__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.agent-dropdown-item__name {
font-size: 13px;
font-weight: 600;
color: var(--mc-text-primary);
}
.agent-dropdown-item__desc {
font-size: 11px;
color: var(--mc-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-dropdown-item__check {
flex-shrink: 0;
color: var(--mc-primary);
}
.agent-dropdown-empty {
padding: 16px;
text-align: center;
font-size: 13px;
color: var(--mc-text-tertiary);
}
.agent-dropdown-enter-active {
transition: all 0.15s ease-out;
}
.agent-dropdown-leave-active {
transition: all 0.1s ease-in;
}
.agent-dropdown-enter-from {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
.agent-dropdown-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
.conversation-list {
@ -1316,6 +1604,19 @@ function handleCodeCopy(e: MouseEvent) {
color: var(--mc-text-tertiary);
}
.conv-title-input {
width: 100%;
font-size: 13px;
font-weight: 500;
color: var(--mc-text-primary);
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-primary);
border-radius: 6px;
padding: 2px 6px;
outline: none;
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.15);
}
.conv-delete {
opacity: 0;
width: 22px;
@ -1478,15 +1779,142 @@ function handleCodeCopy(e: MouseEvent) {
color: var(--mc-text-tertiary);
}
.model-select {
min-width: 260px;
/* Model selector */
.model-selector-wrap {
position: relative;
}
.model-select-trigger {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 12px;
border: 1px solid var(--mc-border);
border-radius: 12px;
background: var(--mc-panel-raised);
color: var(--mc-text-primary);
font-size: 13px;
padding: 0 12px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
max-width: 280px;
}
.model-select-trigger:hover {
border-color: var(--mc-primary);
}
.model-select-trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.model-select-trigger__name {
overflow: hidden;
text-overflow: ellipsis;
}
.model-select-trigger__arrow {
flex-shrink: 0;
color: var(--mc-text-tertiary);
transition: transform 0.2s;
}
.model-select-trigger__arrow.open {
transform: rotate(180deg);
}
.model-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 100;
min-width: 260px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
padding: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
max-height: 360px;
overflow-y: auto;
}
.model-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 0.12s;
}
.model-dropdown-item:hover {
background: var(--mc-bg-sunken);
}
.model-dropdown-item.active {
background: var(--mc-primary-bg);
}
.model-dropdown-item__name {
font-size: 13px;
color: var(--mc-text-primary);
}
.model-dropdown-item__check {
flex-shrink: 0;
color: var(--mc-primary);
}
/* Header overflow menu */
.header-overflow-wrap {
position: relative;
}
.header-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 100;
min-width: 180px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 12px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.header-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 9px 12px;
border: none;
background: none;
border-radius: 8px;
font-size: 13px;
color: var(--mc-text-primary);
cursor: pointer;
transition: background 0.12s;
}
.header-menu-item:hover {
background: var(--mc-bg-sunken);
}
.header-menu-item--danger:hover {
background: var(--mc-danger-bg);
color: var(--mc-danger);
}
.header-menu-divider {
height: 1px;
background: var(--mc-border-light);
margin: 2px 8px;
}
.header-btn {
@ -1628,10 +2056,12 @@ function handleCodeCopy(e: MouseEvent) {
display: none;
}
.model-select {
min-width: 0;
.model-select-trigger {
max-width: 160px;
flex: 1;
}
.model-dropdown {
min-width: 200px;
}
.drop-overlay__content {

View File

@ -10,14 +10,14 @@
</div>
<div class="hero-note mc-surface-card">
<div class="hero-note__label">{{ t('dashboard.periods.today') }}</div>
<div class="hero-note__value">{{ formatTokens(todayStats.totalTokens) }}</div>
<div class="hero-note__meta">{{ t('dashboard.tokens') }} · {{ todayStats.toolCalls }} {{ t('dashboard.toolCalls') }}</div>
<div class="hero-note__value">{{ todayStats.conversations }}</div>
<div class="hero-note__meta">{{ t('dashboard.conversations') }} · {{ todayStats.messages }} {{ t('dashboard.messages') }}</div>
</div>
</div>
<div class="dashboard-body">
<div class="stats-grid">
<div class="stat-card mc-surface-card">
<div class="stat-card mc-surface-card stat-card--primary">
<div class="stat-icon">
<el-icon><ChatDotRound /></el-icon>
</div>
@ -26,7 +26,7 @@
<div class="stat-label">{{ t('dashboard.conversations') }}</div>
</div>
</div>
<div class="stat-card mc-surface-card">
<div class="stat-card mc-surface-card stat-card--primary">
<div class="stat-icon">
<el-icon><Document /></el-icon>
</div>
@ -35,16 +35,7 @@
<div class="stat-label">{{ t('dashboard.messages') }}</div>
</div>
</div>
<div class="stat-card mc-surface-card">
<div class="stat-icon">
<el-icon><DataLine /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value">{{ formatTokens(todayStats.totalTokens) }}</div>
<div class="stat-label">{{ t('dashboard.tokens') }}</div>
</div>
</div>
<div class="stat-card mc-surface-card">
<div class="stat-card mc-surface-card stat-card--secondary">
<div class="stat-icon">
<el-icon><Tools /></el-icon>
</div>
@ -53,6 +44,15 @@
<div class="stat-label">{{ t('dashboard.toolCalls') }}</div>
</div>
</div>
<div class="stat-card mc-surface-card stat-card--secondary">
<div class="stat-icon">
<el-icon><DataLine /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value">{{ formatTokens(todayStats.totalTokens) }}</div>
<div class="stat-label">{{ t('dashboard.tokens') }}</div>
</div>
</div>
</div>
<div v-if="trendData.length" class="trend-section">
@ -372,6 +372,10 @@ function calcDuration(run: any): string {
.stat-value { font-size: 30px; font-weight: 800; color: var(--mc-text-primary); line-height: 1; letter-spacing: -0.05em; }
.stat-label { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 6px; text-transform: uppercase; letter-spacing: 0.08em; }
.stat-card--secondary { opacity: 0.75; }
.stat-card--secondary .stat-icon { background: linear-gradient(135deg, rgba(148, 163, 184, 0.12), rgba(148, 163, 184, 0.06)); color: var(--mc-text-secondary); }
.stat-card--secondary .stat-value { font-size: 24px; }
.trend-section { margin-bottom: 22px; }
.trend-chart { padding: 18px; }
.chart-container { width: 100%; height: 240px; }

View File

@ -632,16 +632,7 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
box-shadow: inset 0 0 0 1px rgba(217, 109, 70, 0.08);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
background: var(--mc-primary);
border-radius: 0 3px 3px 0;
}
/* Active indicator bar removed — active state uses bg color + font weight only */
.nav-icon { display: flex; align-items: center; flex-shrink: 0; }
.nav-label { overflow: hidden; text-overflow: ellipsis; }