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); 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 * 更新会话的流状态running / idle
*/ */

View File

@ -68,6 +68,24 @@ public class ConversationController {
return R.ok(); 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}`), http.delete(`/conversations/${conversationId}`),
clearMessages: (conversationId: string) => clearMessages: (conversationId: string) =>
http.delete(`/conversations/${conversationId}/messages`), http.delete(`/conversations/${conversationId}/messages`),
rename: (conversationId: string, title: string) =>
http.put(`/conversations/${conversationId}/title`, { title }),
} }
// ==================== Skill ==================== // ==================== Skill ====================

View File

@ -1,65 +1,67 @@
<template> <template>
<div class="workspace-switcher" :class="{ collapsed }"> <div class="workspace-switcher" :class="{ collapsed }">
<el-dropdown <button
v-if="workspaces.length > 0" class="ws-trigger"
trigger="click" :title="collapsed ? currentLabel : ''"
:teleported="true" @click="open = !open"
@command="onSwitch"
> >
<button class="ws-trigger" :title="collapsed ? currentLabel : ''"> <span class="ws-trigger__icon">
<span class="ws-icon"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" 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"/> <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"/> <path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg> </svg>
</span> <span class="ws-item__name">{{ ws.name }}</span>
<span v-if="!collapsed" class="ws-name">{{ currentLabel }}</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>
<svg v-if="!collapsed" class="ws-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> </div>
<polyline points="6 9 12 15 18 9"/> <div class="ws-divider"></div>
</svg> <div class="ws-item ws-item--manage" @click="onManage">
</button> <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"/>
<template #dropdown> </svg>
<el-dropdown-menu> <span class="ws-item__name">{{ t('common.manageWorkspaces') }}</span>
<el-dropdown-item </div>
v-for="ws in workspaces" </div>
:key="ws.id" </Transition>
: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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore' import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
defineProps<{ defineProps<{
collapsed?: boolean collapsed?: boolean
}>() }>()
const { t } = useI18n()
const store = useWorkspaceStore() const store = useWorkspaceStore()
const router = useRouter() const router = useRouter()
const open = ref(false)
const workspaces = computed(() => store.workspaces) const workspaces = computed(() => store.workspaces)
const currentWorkspaceId = computed(() => store.currentWorkspaceId) const currentWorkspaceId = computed(() => store.currentWorkspaceId)
const currentLabel = computed(() => store.currentWorkspace?.name || 'Workspace') const currentLabel = computed(() => store.currentWorkspace?.name || 'Workspace')
@ -68,45 +70,51 @@ onMounted(() => {
store.fetchWorkspaces() store.fetchWorkspaces()
}) })
function onSwitch(id: number | string) { function onSelect(id: number) {
if (id === '__manage__') { open.value = false
router.push('/settings/workspaces') if (id !== currentWorkspaceId.value) {
return store.switchWorkspace(id)
} }
store.switchWorkspace(id as number) }
function onManage() {
open.value = false
router.push('/settings/workspaces')
} }
</script> </script>
<style scoped> <style scoped>
.workspace-switcher { .workspace-switcher {
padding: 8px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter, #ebeef5); position: relative;
} }
.workspace-switcher.collapsed { .workspace-switcher.collapsed {
padding: 8px 4px; padding: 8px 6px;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
/* Trigger */
.ws-trigger { .ws-trigger {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding: 6px 8px; padding: 7px 10px;
border: none; border: 1px solid var(--mc-sidebar-border, var(--mc-border-light));
border-radius: 6px; border-radius: 12px;
background: var(--el-fill-color-light, #f5f7fa); background: var(--mc-sidebar-hover, rgba(0,0,0,0.04));
color: var(--el-text-color-primary, #303133); color: var(--mc-sidebar-text, var(--mc-text-primary));
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 600;
transition: background 0.2s; transition: all 0.15s;
} }
.ws-trigger:hover { .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 { .collapsed .ws-trigger {
@ -114,36 +122,129 @@ function onSwitch(id: number | string) {
height: 36px; height: 36px;
padding: 0; padding: 0;
justify-content: center; justify-content: center;
border-radius: 10px;
} }
.ws-icon { .ws-trigger__icon {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
opacity: 0.7; color: var(--mc-accent, var(--mc-primary));
} }
.ws-name { .ws-trigger__name {
flex: 1; flex: 1;
text-align: left; text-align: left;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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; flex-shrink: 0;
opacity: 0.5; opacity: 0.5;
} }
.ws-menu-icon { .ws-item.active .ws-item__icon {
display: inline-flex; opacity: 1;
margin-right: 6px; color: var(--mc-primary);
opacity: 0.6;
} }
:deep(.is-active) { .ws-item__name {
color: var(--el-color-primary); flex: 1;
font-weight: 600; 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> </style>

View File

@ -22,6 +22,7 @@ export default {
search: 'Search', search: 'Search',
expandSidebar: 'Expand sidebar', expandSidebar: 'Expand sidebar',
collapseSidebar: 'Collapse sidebar', collapseSidebar: 'Collapse sidebar',
manageWorkspaces: 'Manage Workspaces',
show: 'Show', show: 'Show',
hide: 'Hide', hide: 'Hide',
on: 'On', on: 'On',
@ -115,6 +116,7 @@ export default {
loadConversationsFailed: 'Failed to load conversations', loadConversationsFailed: 'Failed to load conversations',
loadMessagesFailed: 'Failed to load messages', loadMessagesFailed: 'Failed to load messages',
deleteConversationFailed: 'Failed to delete conversation', deleteConversationFailed: 'Failed to delete conversation',
deleteConfirm: 'Delete this conversation? This cannot be undone.',
switchModelFailed: 'Failed to switch model', switchModelFailed: 'Failed to switch model',
uploadFailed: 'File upload failed', uploadFailed: 'File upload failed',
dropToUpload: 'Drop files or folders here', dropToUpload: 'Drop files or folders here',
@ -597,6 +599,10 @@ export default {
planExecute: 'Plan-Execute', planExecute: 'Plan-Execute',
enabled: 'Enabled', enabled: 'Enabled',
disabled: 'Disabled', disabled: 'Disabled',
basic: 'Basic',
skills: 'Skills',
tools: 'Tools',
context: 'Context',
}, },
columns: { columns: {
name: 'Name', name: 'Name',
@ -656,6 +662,14 @@ export default {
toggleFailed: 'Failed to toggle agent status', toggleFailed: 'Failed to toggle agent status',
toggleSuccess: 'Status updated', 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: { security: {
title: 'Security', title: 'Security',
@ -727,6 +741,7 @@ export default {
workspaces: { workspaces: {
title: 'Workspaces', title: 'Workspaces',
desc: 'Manage workspaces for your organization.', desc: 'Manage workspaces for your organization.',
manage: 'Manage Workspaces',
newWorkspace: 'New Workspace', newWorkspace: 'New Workspace',
loading: 'Loading workspaces...', loading: 'Loading workspaces...',
noWorkspaces: 'No workspaces found.', 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.', 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.', runsDesc: 'Execution history with timing, cost, and outcome at a glance.',
trend: { trend: {
title: '7-Day Trend', title: 'Your Week',
subtitle: 'Messages and token consumption over the past week.', subtitle: 'How often you and your AI have been working together.',
}, },
periodComparison: 'Period Comparison', periodComparison: 'Period Comparison',
periods: { periods: {

View File

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

View File

@ -207,6 +207,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { agentApi, agentContextApi } from '@/api/index' import { agentApi, agentContextApi } from '@/api/index'
@ -262,8 +263,15 @@ const sortedFiles = computed(() => {
const renderedMarkdown = computed(() => renderMarkdown(fileContent.value || '')) const renderedMarkdown = computed(() => renderMarkdown(fileContent.value || ''))
const route = useRoute()
onMounted(async () => { onMounted(async () => {
await loadAgents() 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, () => { watch(selectedAgentId, () => {

View File

@ -33,50 +33,39 @@
</div> </div>
</div> </div>
<!-- Agent table --> <!-- Agent card grid -->
<div class="table-wrap mc-surface-card" v-if="filteredAgents.length > 0"> <div class="agent-grid" v-if="filteredAgents.length > 0">
<table class="agent-table"> <div
<thead> v-for="agent in filteredAgents"
<tr> :key="agent.id"
<th class="col-name">{{ t('agents.columns.name') }}</th> class="agent-card mc-surface-card"
<th class="col-type">{{ t('agents.columns.agentType') }}</th> :class="{ 'agent-card--disabled': !agent.enabled }"
<th class="col-tags">{{ t('agents.columns.tags') }}</th> >
<th class="col-status">{{ t('agents.columns.enabled') }}</th> <div class="agent-card__header">
<th class="col-time">{{ t('agents.columns.updateTime') }}</th> <span class="agent-card__icon">{{ agent.icon || '🤖' }}</span>
<th class="col-actions">{{ t('agents.columns.actions') }}</th> <label class="toggle-switch toggle-switch--sm">
</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">
<input type="checkbox" :checked="agent.enabled" @change="toggleAgent(agent)" /> <input type="checkbox" :checked="agent.enabled" @change="toggleAgent(agent)" />
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</td> </div>
<td class="col-time"> <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> <span class="time-label">{{ formatTime(agent.updateTime) }}</span>
</td> <div class="agent-card__actions">
<td class="col-actions"> <button class="action-btn" :title="t('agents.tabs.context')" @click="goToAgentContextFor(agent)">
<div class="action-btns"> <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)"> <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"> <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"/> <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> </svg>
</button> </button>
</div> </div>
</td> </div>
</tr> </div>
</tbody>
</table>
</div> </div>
<!-- Empty state --> <!-- Empty state -->
@ -221,8 +208,8 @@
<!-- Skills Tab --> <!-- Skills Tab -->
<div v-if="modalTab === 'skills'" class="binding-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> <p class="binding-hint">{{ t('agents.binding.skillsHint') }}</p>
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills', 'No skills available') }}</div> <div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div>
<div v-else class="binding-list"> <div v-else class="binding-list">
<label <label
v-for="skill in availableSkills" v-for="skill in availableSkills"
@ -243,8 +230,8 @@
<!-- Tools Tab --> <!-- Tools Tab -->
<div v-if="modalTab === 'tools'" class="binding-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> <p class="binding-hint">{{ t('agents.binding.toolsHint') }}</p>
<div v-if="availableTools.length === 0" class="binding-empty">{{ t('agents.binding.noTools', 'No tools available') }}</div> <div v-if="availableTools.length === 0" class="binding-empty">{{ t('agents.binding.noTools') }}</div>
<div v-else class="binding-list"> <div v-else class="binding-list">
<label <label
v-for="tool in availableTools" v-for="tool in availableTools"
@ -276,11 +263,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { agentApi, agentBindingApi, skillApi, toolApi, templateApi } from '@/api/index' import { agentApi, agentBindingApi, skillApi, toolApi, templateApi } from '@/api/index'
import type { Agent } from '@/types/index' import type { Agent } from '@/types/index'
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const agents = ref<Agent[]>([]) const agents = ref<Agent[]>([])
const searchText = ref('') 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) { async function toggleAgent(agent: Agent) {
try { try {
await agentApi.update(agent.id, { ...agent, enabled: !agent.enabled }) 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: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; } .filter-tab.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-primary); font-weight: 500; }
/* Table */ /* Agent Card Grid */
.table-wrap { overflow-x: auto; } .agent-grid {
.agent-table { width: 100%; border-collapse: collapse; font-size: 14px; } display: grid;
.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; } grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
.agent-table td { padding: 12px 16px; border-bottom: 1px solid var(--mc-border-light); vertical-align: middle; } gap: 16px;
.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; }
.col-name { min-width: 200px; } .agent-card {
.col-type { min-width: 110px; } display: flex;
.col-tags { min-width: 120px; } flex-direction: column;
.col-status { min-width: 80px; } gap: 12px;
.col-time { min-width: 140px; } padding: 20px;
.col-actions { min-width: 90px; } transition: all 0.15s;
cursor: default;
}
.agent-name-cell { display: flex; align-items: center; gap: 10px; } .agent-card:hover {
.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; } transform: translateY(-2px);
.agent-name-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; } box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
.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--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; } .tag { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.type-tag { background: var(--mc-primary-bg); color: var(--mc-primary); } .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> <div class="panel-kicker">{{ $t('nav.chat') }}</div>
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2> <h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
</div> </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> <el-icon><Plus /></el-icon>
</button> </button>
</div> </div>
<div class="agent-selector"> <div class="agent-selector">
<select v-model="selectedAgentId" class="agent-select" @change="onAgentChange"> <button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="`${$t('chat.selectAgent')} (⌘K)`">
<option v-if="agents.length === 0" value="">{{ $t('chat.loadingAgents') }}</option> <span class="agent-select-trigger__icon">{{ currentAgent?.icon || '🤖' }}</span>
<option v-for="agent in agents" :key="agent.id" :value="agent.id"> <span class="agent-select-trigger__name">{{ currentAgent?.name || $t('chat.selectAgent') }}</span>
{{ agent.icon || '🤖' }} {{ agent.name }} <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>
</option> </button>
</select> <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>
<div class="conversation-list"> <div class="conversation-list">
@ -42,14 +65,24 @@
<img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" /> <img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" />
</div> </div>
<div class="conv-info"> <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"> <div class="conv-meta">
<span>{{ $t('chat.messages', { count: conv.messageCount }) }}</span> <span>{{ $t('chat.messages', { count: conv.messageCount }) }}</span>
<span class="conv-dot">·</span> <span class="conv-dot">·</span>
<span>{{ formatConversationTime(conv.lastActiveTime) }}</span> <span>{{ formatConversationTime(conv.lastActiveTime) }}</span>
</div> </div>
</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> <el-icon><Delete /></el-icon>
</button> </button>
</div> </div>
@ -97,23 +130,57 @@
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div> <div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
</div> </div>
<div class="chat-header-right"> <div class="chat-header-right">
<select <!-- Model selector -->
v-if="eligibleModels.length > 0" <div v-if="eligibleModels.length > 0" class="model-selector-wrap">
:value="activeModelValue" <button class="model-select-trigger" :disabled="modelSaving" @click="modelDropdownOpen = !modelDropdownOpen">
class="model-select" <span class="model-select-trigger__name">{{ activeModelLabel || $t('chat.configModel') }}</span>
:disabled="modelSaving" <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>
@change="onModelChange" </button>
> <Transition name="fade">
<option v-for="item in eligibleModels" :key="item.value" :value="item.value"> <div v-if="modelDropdownOpen" class="model-dropdown-backdrop" @click="modelDropdownOpen = false"></div>
{{ item.label }} </Transition>
</option> <Transition name="agent-dropdown">
</select> <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')"> <button v-else class="header-btn" @click="goToModelSettings" :title="$t('chat.configModel')">
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
</button> </button>
<button class="header-btn" @click="clearMessages" :title="$t('chat.clearMessages')"> <!-- Overflow menu -->
<el-icon><Delete /></el-icon> <div class="header-overflow-wrap">
</button> <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>
</div> </div>
@ -162,7 +229,7 @@
:loading="isGenerating && !hasPendingApproval" :loading="isGenerating && !hasPendingApproval"
:disabled="showModelPrompt || !currentAgent" :disabled="showModelPrompt || !currentAgent"
:placeholder="$t('chat.messagePlaceholder')" :placeholder="$t('chat.messagePlaceholder')"
:hint="`MateClaw · ${currentRuntimeModel}` + (currentConversationId ? ` · Session · ${currentConversationId.slice(0, 8)}...` : '')" :hint="currentRuntimeModel"
:attachments="pendingAttachments" :attachments="pendingAttachments"
:uploading="uploadingAttachment" :uploading="uploadingAttachment"
:max-length="10240" :max-length="10240"
@ -195,10 +262,10 @@
</template> </template>
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' 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 { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
import { conversationApi, agentApi, modelApi, chatApi } from '@/api/index' import { conversationApi, agentApi, modelApi, chatApi } from '@/api/index'
import { channelIconUrl } from '@/utils/channelSource' import { channelIconUrl } from '@/utils/channelSource'
@ -227,12 +294,30 @@ function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
} }
// ============ ============ // ============ ============
const suggestions = computed(() => [ const suggestions = computed(() => {
t('chat.suggestionIntro'), const agent = currentAgent.value
t('chat.suggestionPoem'), // If agent has custom suggestions (stored as newline-separated string in description etc.)
t('chat.suggestionCode'), const agentSuggestions = (agent as any)?.suggestions as string | undefined
t('chat.suggestionWeather'), 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() const router = useRouter()
@ -252,6 +337,80 @@ const activeModels = ref<ActiveModelsInfo | null>(null)
const pendingAttachments = ref<ChatAttachment[]>([]) const pendingAttachments = ref<ChatAttachment[]>([])
const uploadingAttachment = ref(false) 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) const isDragging = ref(false)
let dragCounter = 0 let dragCounter = 0
@ -450,6 +609,12 @@ const activeModelValue = computed(() => {
return providerId && model ? `${providerId}::${model}` : '' 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 activeProvider = computed(() => {
const providerId = activeModels.value?.activeLlm?.providerId const providerId = activeModels.value?.activeLlm?.providerId
return providerId ? providers.value.find((provider) => provider.id === providerId) || null : null 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 () => { onMounted(async () => {
document.addEventListener('keydown', handleKeyboardShortcuts)
document.addEventListener('click', handleCodeCopy) document.addEventListener('click', handleCodeCopy)
startECharts() startECharts()
mobileQuery = window.matchMedia('(max-width: 768px)') mobileQuery = window.matchMedia('(max-width: 768px)')
@ -499,6 +678,7 @@ onMounted(async () => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeyboardShortcuts)
document.removeEventListener('click', handleCodeCopy) document.removeEventListener('click', handleCodeCopy)
disposeECharts() disposeECharts()
mobileQuery?.removeEventListener('change', handleMobileChange) mobileQuery?.removeEventListener('change', handleMobileChange)
@ -701,26 +881,9 @@ async function clearMessages() {
} }
} }
function onAgentChange() { // onAgentChange removed replaced by selectAgent()
newConversation()
}
async function onModelChange(event: Event) { // onModelChange removed replaced by selectModel()
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
}
}
function goToModelSettings() { function goToModelSettings() {
router.push('/settings/models') router.push('/settings/models')
@ -1219,11 +1382,15 @@ function handleCodeCopy(e: MouseEvent) {
.agent-selector { .agent-selector {
padding: 10px 12px 12px; padding: 10px 12px 12px;
border-bottom: 1px solid var(--mc-border-light); border-bottom: 1px solid var(--mc-border-light);
position: relative;
} }
.agent-select { .agent-select-trigger {
width: 100%; width: 100%;
padding: 9px 12px; display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--mc-border); border: 1px solid var(--mc-border);
border-radius: 12px; border-radius: 12px;
font-size: 13px; font-size: 13px;
@ -1231,11 +1398,132 @@ function handleCodeCopy(e: MouseEvent) {
background: var(--mc-bg-sunken); background: var(--mc-bg-sunken);
cursor: pointer; cursor: pointer;
outline: none; outline: none;
transition: all 0.15s;
} }
.agent-select:focus { .agent-select-trigger:hover {
border-color: var(--mc-primary); 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 { .conversation-list {
@ -1316,6 +1604,19 @@ function handleCodeCopy(e: MouseEvent) {
color: var(--mc-text-tertiary); 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 { .conv-delete {
opacity: 0; opacity: 0;
width: 22px; width: 22px;
@ -1478,15 +1779,142 @@ function handleCodeCopy(e: MouseEvent) {
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
} }
.model-select { /* Model selector */
min-width: 260px; .model-selector-wrap {
position: relative;
}
.model-select-trigger {
display: flex;
align-items: center;
gap: 6px;
height: 34px; height: 34px;
padding: 0 12px;
border: 1px solid var(--mc-border); border: 1px solid var(--mc-border);
border-radius: 12px; border-radius: 12px;
background: var(--mc-panel-raised); background: var(--mc-panel-raised);
color: var(--mc-text-primary); color: var(--mc-text-primary);
font-size: 13px; 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 { .header-btn {
@ -1628,10 +2056,12 @@ function handleCodeCopy(e: MouseEvent) {
display: none; display: none;
} }
.model-select { .model-select-trigger {
min-width: 0;
max-width: 160px; max-width: 160px;
flex: 1; }
.model-dropdown {
min-width: 200px;
} }
.drop-overlay__content { .drop-overlay__content {

View File

@ -10,14 +10,14 @@
</div> </div>
<div class="hero-note mc-surface-card"> <div class="hero-note mc-surface-card">
<div class="hero-note__label">{{ t('dashboard.periods.today') }}</div> <div class="hero-note__label">{{ t('dashboard.periods.today') }}</div>
<div class="hero-note__value">{{ formatTokens(todayStats.totalTokens) }}</div> <div class="hero-note__value">{{ todayStats.conversations }}</div>
<div class="hero-note__meta">{{ t('dashboard.tokens') }} · {{ todayStats.toolCalls }} {{ t('dashboard.toolCalls') }}</div> <div class="hero-note__meta">{{ t('dashboard.conversations') }} · {{ todayStats.messages }} {{ t('dashboard.messages') }}</div>
</div> </div>
</div> </div>
<div class="dashboard-body"> <div class="dashboard-body">
<div class="stats-grid"> <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"> <div class="stat-icon">
<el-icon><ChatDotRound /></el-icon> <el-icon><ChatDotRound /></el-icon>
</div> </div>
@ -26,7 +26,7 @@
<div class="stat-label">{{ t('dashboard.conversations') }}</div> <div class="stat-label">{{ t('dashboard.conversations') }}</div>
</div> </div>
</div> </div>
<div class="stat-card mc-surface-card"> <div class="stat-card mc-surface-card stat-card--primary">
<div class="stat-icon"> <div class="stat-icon">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
</div> </div>
@ -35,16 +35,7 @@
<div class="stat-label">{{ t('dashboard.messages') }}</div> <div class="stat-label">{{ t('dashboard.messages') }}</div>
</div> </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><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-icon"> <div class="stat-icon">
<el-icon><Tools /></el-icon> <el-icon><Tools /></el-icon>
</div> </div>
@ -53,6 +44,15 @@
<div class="stat-label">{{ t('dashboard.toolCalls') }}</div> <div class="stat-label">{{ t('dashboard.toolCalls') }}</div>
</div> </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>
<div v-if="trendData.length" class="trend-section"> <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-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-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-section { margin-bottom: 22px; }
.trend-chart { padding: 18px; } .trend-chart { padding: 18px; }
.chart-container { width: 100%; height: 240px; } .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); box-shadow: inset 0 0 0 1px rgba(217, 109, 70, 0.08);
} }
.nav-item.active::before { /* Active indicator bar removed — active state uses bg color + font weight only */
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
background: var(--mc-primary);
border-radius: 0 3px 3px 0;
}
.nav-icon { display: flex; align-items: center; flex-shrink: 0; } .nav-icon { display: flex; align-items: center; flex-shrink: 0; }
.nav-label { overflow: hidden; text-overflow: ellipsis; } .nav-label { overflow: hidden; text-overflow: ellipsis; }