mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
refactor(ux): simplify interactions and humanize UI with Jobs-inspired review
This commit is contained in:
parent
4cd373dcdd
commit
1c2acfd2e9
@ -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)
|
||||
*/
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空会话消息(保留会话记录)
|
||||
*/
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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, () => {
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user