perf: improve ui (#7178)

* perf: enhance layout responsiveness and text handling in stats page

* perf: enhance subagent, future task UI

- Updated the overall structure of the SubAgentPage component for better readability and maintainability.
- Introduced a new dashboard layout with improved header and action buttons.
- Replaced the old settings card with a more visually appealing setting card design.
- Enhanced the agent list section with a more user-friendly display and added empty state handling.
- Implemented unsaved changes notification and confirmation dialogs for better user interaction.
- Refactored code for better clarity and organization, including the use of computed properties for state management.
This commit is contained in:
Soulter
2026-03-30 23:53:27 +08:00
committed by GitHub
parent 55f9903b2f
commit 410f30bf26
17 changed files with 1523 additions and 412 deletions

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 252 icons */
/* Auto-generated MDI subset 256 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -128,10 +128,22 @@
content: "\F00ED";
}
.mdi-calendar-blank-outline::before {
content: "\F0B66";
}
.mdi-calendar-clock-outline::before {
content: "\F16E1";
}
.mdi-calendar-edit::before {
content: "\F08A7";
}
.mdi-calendar-multiple::before {
content: "\F00F1";
}
.mdi-calendar-plus::before {
content: "\F00F3";
}
@@ -832,6 +844,10 @@
content: "\F048A";
}
.mdi-send-clock-outline::before {
content: "\F1164";
}
.mdi-server::before {
content: "\F048B";
}

View File

@@ -31,6 +31,10 @@
"createdAt": "Created At",
"updatedAt": "Updated At",
"actions": "Actions"
},
"umoDisplay": {
"parsed": "Parsed",
"raw": "UMO"
}
},
"actions": {
@@ -97,6 +101,8 @@
"batchDeletePartial": "Delete completed: {deleted} successful, {failed} failed",
"exportSuccess": "Export successful",
"exportError": "Export failed",
"noItemSelectedForExport": "Please select conversations to export first"
"noItemSelectedForExport": "Please select conversations to export first",
"copySuccess": "Copied",
"copyError": "Copy failed"
}
}
}

View File

@@ -1,10 +1,14 @@
{
"header": {
"eyebrow": "Automation",
"live": "Live Sync"
},
"page": {
"title": "Future Task Management",
"beta": "Experimental",
"subtitle": "See scheduled tasks for AstrBot. AstrBot will wake up, run them, and deliver the results.",
"proactive": {
"supported": "Proactive delivery is available on: {platforms}",
"supported": "Proactive delivery is available only on the configured platforms below",
"unsupported": "No proactive messaging platforms enabled. Turn them on in Platform settings."
}
},
@@ -15,8 +19,41 @@
"cancel": "Cancel",
"submit": "Create"
},
"overview": {
"totalTasks": "Total Tasks",
"totalTasksNote": "Registered future tasks",
"enabledTasks": "Enabled Tasks",
"enabledTasksNote": "Tasks ready to wake and run",
"oneOffTasks": "One-off Tasks",
"recurringTasksNote": "{count} recurring tasks",
"proactivePlatforms": "Proactive Platforms",
"proactivePlatformsNote": "Platforms able to deliver results proactively"
},
"section": {
"registered": {
"title": "Task List",
"subtitle": "Review the registered tasks, execution timing, and current state"
},
"delivery": {
"title": "Delivery Status",
"subtitle": "After a task runs, results are proactively sent back through supported platforms",
"support": "Proactive delivery",
"available": "Available",
"unavailable": "Unavailable",
"enabledPlatforms": "Enabled platforms"
},
"quickCreate": {
"title": "Quick Create",
"runMode": "Task mode",
"target": "Targeting"
},
"platforms": {
"title": "Supported Platforms"
}
},
"table": {
"title": "Registered Tasks",
"subtitle": "Track cron setup, session target, last run history, and enabled state",
"empty": "No tasks yet.",
"headers": {
"name": "Name",

View File

@@ -1,4 +1,7 @@
{
"header": {
"eyebrow": "Orchestration"
},
"page": {
"title": "SubAgent Orchestration",
"beta": "Experimental",
@@ -8,9 +11,20 @@
"refresh": "Refresh",
"save": "Save",
"add": "Add SubAgent",
"expand": "Expand",
"collapse": "Collapse",
"delete": "Delete",
"close": "Close"
},
"overview": {
"totalAgents": "Total SubAgents",
"totalAgentsNote": "Configured delegate agents",
"enabledAgents": "Enabled Agents",
"enabledAgentsNote": "Agents available for handoff",
"mainOrchestration": "Main Orchestration",
"boundPersonas": "Bound Personas",
"boundPersonasNote": "Agents with an attached persona"
},
"switches": {
"enable": "Enable SubAgent orchestration",
"enableHint": "Enable sub-agent functionality",
@@ -23,7 +37,9 @@
},
"section": {
"title": "SubAgents",
"globalSettings": "Global Settings"
"subtitle": "Configure delegate agents, personas, and descriptions for the main LLM",
"globalSettings": "Global Settings",
"agentSetup": "Agent Setup"
},
"cards": {
"statusEnabled": "Enabled",
@@ -33,7 +49,9 @@
"switchLabel": "Enable",
"previewTitle": "Preview: handoff tool shown to the main LLM",
"personaChip": "Persona: {id}",
"personaPreview": "PERSONA PREVIEW"
"personaPreview": "Persona Preview",
"noDescription": "No description yet",
"previewHint": "Review the currently selected persona to verify the handoff target."
},
"form": {
"nameLabel": "Agent name (used for transfer_to_{name})",
@@ -48,6 +66,9 @@
"messages": {
"loadConfigFailed": "Failed to load config",
"loadPersonaFailed": "Failed to load persona list",
"unsavedChangesNotice": "You have unsaved changes on this page. Save before leaving.",
"unsavedChangesLeaveConfirm": "You have unsaved changes. Leaving will discard them. Continue?",
"unsavedChangesReloadConfirm": "You have unsaved changes. Reloading will discard them. Continue?",
"nameMissing": "A SubAgent is missing a name",
"nameInvalid": "Invalid SubAgent name: only lowercase letters/numbers/underscores, starting with a letter",
"nameDuplicate": "Duplicate SubAgent name: {name}",

View File

@@ -31,6 +31,10 @@
"createdAt": "Создан",
"updatedAt": "Обновлен",
"actions": "Действия"
},
"umoDisplay": {
"parsed": "Разобрано",
"raw": "UMO"
}
},
"actions": {
@@ -97,6 +101,8 @@
"batchDeletePartial": "Удаление завершено: успешно {deleted}, ошибок {failed}",
"exportSuccess": "Экспорт завершен",
"exportError": "Ошибка экспорта",
"noItemSelectedForExport": "Сначала выберите диалоги для экспорта"
"noItemSelectedForExport": "Сначала выберите диалоги для экспорта",
"copySuccess": "Скопировано",
"copyError": "Не удалось скопировать"
}
}
}

View File

@@ -1,10 +1,14 @@
{
"header": {
"eyebrow": "Automation",
"live": "Живая синхронизация"
},
"page": {
"title": "Запланированные задачи",
"beta": "Экспериментальные функции",
"subtitle": "Управление будущими задачами AstrBot. Бот автоматически проснется, выполнит задачу и отправит результат. Требуется включить «Проактивные способности» в конфигурации.",
"proactive": {
"supported": "Отправка результатов поддерживается на платформах: {platforms}",
"supported": "Отправка результатов поддерживается только на указанных ниже настроенных платформах",
"unsupported": "Нет платформ, поддерживающих проактивные сообщения. Включите их в настройках платформ."
}
},
@@ -15,8 +19,41 @@
"cancel": "Отмена",
"submit": "Создать"
},
"overview": {
"totalTasks": "Всего задач",
"totalTasksNote": "Все зарегистрированные будущие задачи",
"enabledTasks": "Активные задачи",
"enabledTasksNote": "Задачи, которые будут автоматически выполнены",
"oneOffTasks": "Разовые задачи",
"recurringTasksNote": "Повторяющихся задач: {count}",
"proactivePlatforms": "Проактивные платформы",
"proactivePlatformsNote": "Платформы, способные отправлять результат сами"
},
"section": {
"registered": {
"title": "Список задач",
"subtitle": "Просматривайте зарегистрированные задачи, время запуска и состояние"
},
"delivery": {
"title": "Статус доставки",
"subtitle": "После выполнения задачи результат будет отправлен обратно через поддерживаемые платформы",
"support": "Проактивная доставка",
"available": "Доступна",
"unavailable": "Недоступна",
"enabledPlatforms": "Включенные платформы"
},
"quickCreate": {
"title": "Быстрое создание",
"runMode": "Режим задачи",
"target": "Целевой контекст"
},
"platforms": {
"title": "Поддерживаемые платформы"
}
},
"table": {
"title": "Список задач",
"subtitle": "Отслеживайте cron, целевую сессию, историю запусков и состояние",
"empty": "Задач пока нет.",
"headers": {
"name": "Имя",
@@ -63,4 +100,4 @@
"createSuccess": "Задача создана",
"createFailed": "Ошибка создания"
}
}
}

View File

@@ -1,4 +1,7 @@
{
"header": {
"eyebrow": "Orchestration"
},
"page": {
"title": "Оркестрация SubAgent",
"beta": "Экспериментально",
@@ -8,9 +11,20 @@
"refresh": "Обновить",
"save": "Сохранить",
"add": "Добавить SubAgent",
"expand": "Развернуть",
"collapse": "Свернуть",
"delete": "Удалить",
"close": "Закрыть"
},
"overview": {
"totalAgents": "Всего SubAgent",
"totalAgentsNote": "Количество настроенных под-агентов",
"enabledAgents": "Активные агенты",
"enabledAgentsNote": "Под-агенты, доступные для handoff",
"mainOrchestration": "Основная оркестрация",
"boundPersonas": "Привязанные персонажи",
"boundPersonasNote": "Под-агенты с выбранной персоной"
},
"switches": {
"enable": "Включить оркестрацию SubAgent",
"enableHint": "Включить функциональность под-агентов",
@@ -23,7 +37,9 @@
},
"section": {
"title": "Субагенты",
"globalSettings": "Глобальные настройки"
"subtitle": "Настройте делегируемых агентов, персонажей и описания для основного LLM",
"globalSettings": "Глобальные настройки",
"agentSetup": "Настройка агента"
},
"cards": {
"statusEnabled": "Включено",
@@ -33,7 +49,9 @@
"switchLabel": "Включить",
"previewTitle": "Предпросмотр: инструмент handoff, видимый основному LLM",
"personaChip": "Персонаж: {id}",
"personaPreview": РЕДПРОСМОТР ПЕРСОНАЖА"
"personaPreview": редпросмотр персонажа",
"noDescription": "Описание пока не задано",
"previewHint": "Просмотрите выбранную персону, чтобы проверить цель handoff."
},
"form": {
"nameLabel": "Имя агента (используется для transfer_to_{name})",
@@ -48,6 +66,9 @@
"messages": {
"loadConfigFailed": "Не удалось загрузить конфигурацию",
"loadPersonaFailed": "Не удалось загрузить список персонажей",
"unsavedChangesNotice": "На странице есть несохраненные изменения. Сохраните их перед уходом.",
"unsavedChangesLeaveConfirm": "Есть несохраненные изменения. При уходе со страницы они будут потеряны. Продолжить?",
"unsavedChangesReloadConfirm": "Есть несохраненные изменения. Обновление конфигурации их сбросит. Продолжить?",
"nameMissing": "У SubAgent отсутствует имя",
"nameInvalid": "Недопустимое имя SubAgent: только строчные латинские буквы/цифры/подчеркивания, должно начинаться с буквы",
"nameDuplicate": "Дублирующееся имя SubAgent: {name}",
@@ -62,4 +83,4 @@
"subtitle": "Добавьте первого под-агента, чтобы начать",
"action": "Создать первого агента"
}
}
}

View File

@@ -5,7 +5,7 @@
"providers": "模型提供商",
"commands": "指令管理",
"persona": "人格设定",
"subagent": "SubAgent 编排",
"subagent": "子代理编排",
"toolUse": "MCP",
"extension": "插件",
"extensionTabs": {

View File

@@ -22,7 +22,7 @@
},
"table": {
"headers": {
"title": "对话标题",
"title": "标题",
"platform": "机器人 ID",
"type": "消息类型",
"cid": "对话 ID",
@@ -31,6 +31,10 @@
"createdAt": "创建时间",
"updatedAt": "更新时间",
"actions": "操作"
},
"umoDisplay": {
"parsed": "解析后",
"raw": "UMO"
}
},
"actions": {
@@ -97,6 +101,8 @@
"batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个",
"exportSuccess": "导出成功",
"exportError": "导出失败",
"noItemSelectedForExport": "请先选择要导出的对话"
"noItemSelectedForExport": "请先选择要导出的对话",
"copySuccess": "复制成功",
"copyError": "复制失败"
}
}
}

View File

@@ -1,22 +1,59 @@
{
"header": {
"eyebrow": "Automation",
"live": "实时同步"
},
"page": {
"title": "未来任务管理",
"beta": "实验性",
"subtitle": "查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒执行任务,然后将结果告知任务布置方。需要先在配置文件中启用“主动型能力”。",
"subtitle": "AstrBot 可以被自动唤醒然后执行任务,将结果告知任务布置方。需要先在配置文件中启用“主动型能力”。",
"proactive": {
"supported": "主动发送结果仅支持以下您已配置的平台{platforms}",
"supported": "主动发送结果仅支持以下您已配置的平台",
"unsupported": "暂无支持主动消息的平台,请在平台设置中开启。"
}
},
"actions": {
"create": "新建任务",
"create": "新建",
"refresh": "刷新",
"delete": "删除",
"cancel": "取消",
"submit": "创建"
},
"overview": {
"totalTasks": "任务总数",
"totalTasksNote": "当前已注册的未来任务",
"enabledTasks": "启用任务",
"enabledTasksNote": "执行器会按计划唤醒并运行",
"oneOffTasks": "一次性任务",
"recurringTasksNote": "循环任务 {count} 个",
"proactivePlatforms": "主动平台",
"proactivePlatformsNote": "已开启主动消息的平台数量"
},
"section": {
"registered": {
"title": "任务列表",
"subtitle": "集中查看当前注册任务、执行时间与启用状态"
},
"delivery": {
"title": "投递状态",
"subtitle": "未来任务执行后的结果,会通过支持主动消息的平台回传",
"support": "主动投递",
"available": "可用",
"unavailable": "不可用",
"enabledPlatforms": "已启用平台"
},
"quickCreate": {
"title": "快速创建",
"runMode": "任务模式",
"target": "目标定位"
},
"platforms": {
"title": "适用平台"
}
},
"table": {
"title": "已注册任务",
"subtitle": "支持查看 cron、会话、最近执行记录与启用状态",
"empty": "暂无任务。",
"headers": {
"name": "名称",

View File

@@ -1,39 +1,57 @@
{
"header": {
"eyebrow": "Subagent Orchestration"
},
"page": {
"title": "SubAgent 编排",
"title": "子代理编排",
"beta": "实验性",
"subtitle": "主 LLM 可直接使用自身工具,也可通过 handoff 分派给各个 SubAgent。"
"subtitle": "主代理可直接使用自身工具,也可将部分任务委派给各个子代理,完成更复杂的任务,避免主代理上下文过长。"
},
"actions": {
"refresh": "刷新",
"save": "保存",
"add": "新增 SubAgent",
"add": "新增子代理",
"expand": "展开设置",
"collapse": "收起设置",
"delete": "删除",
"close": "关闭"
},
"overview": {
"totalAgents": "子代理总数",
"totalAgentsNote": "当前已配置的子代理数量",
"enabledAgents": "启用中的 Agent",
"enabledAgentsNote": "会参与 handoff 编排的子代理",
"mainOrchestration": "主编排状态",
"boundPersonas": "已绑定 Persona",
"boundPersonasNote": "完成角色绑定的子代理数量"
},
"switches": {
"enable": "启用 SubAgent 编排",
"enable": "启用子代理编排",
"enableHint": "启用子代理功能",
"dedupe": "主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)",
"dedupeHint": "从主代理中移除重复工具"
"dedupe": "主 LLM 去重重复工具(与子代理重叠的工具将被隐藏)",
"dedupeHint": "从主代理中移除与子代理重复工具"
},
"description": {
"disabled": "启动SubAgent 关闭;主 LLM 按 persona 规则挂载工具(默认全部),并直接调用。",
"enabled": "启动:主 LLM 会保留自身工具并挂载 transfer_to_* 委派工具。若开启“去重重复工具”,与 SubAgent 指定的工具重叠部分会从主 LLM 工具集中移除。"
"disabled": "启动子代理编排功能。",
"enabled": "子代理将作为工具放在主代理的工具集中,主代理会在适当的时机调用子代理完成任务。"
},
"section": {
"title": "SubAgents 配置",
"globalSettings": "全局设置"
"title": "子代理配置",
"subtitle": "为主代理配置可委派的子代理、人格与描述信息",
"globalSettings": "全局设置",
"agentSetup": "Agent 设置"
},
"cards": {
"statusEnabled": "启用",
"statusDisabled": "停用",
"unnamed": "未命名 SubAgent",
"unnamed": "未命名子代理",
"transferPrefix": "transfer_to_{name}",
"switchLabel": "启用",
"previewTitle": "预览:主 LLM 将看到的 handoff 工具",
"personaChip": "Persona: {id}",
"noDescription": "暂无描述"
"noDescription": "暂无描述",
"personaPreview": "人格预览",
"previewHint": "这里展示当前所选 Persona 的预览,便于确认 handoff 目标。"
},
"form": {
"nameLabel": "Agent 名称(用于 transfer_to_{name}",
@@ -41,7 +59,7 @@
"providerLabel": "Chat Provider可选",
"providerHint": "留空表示跟随全局默认 provider。",
"personaLabel": "选择人格设定",
"personaHint": "SubAgent 将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
"personaHint": "子代理 将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
"personaPreview": "人格预览",
"descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff",
"descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。"
@@ -49,17 +67,20 @@
"messages": {
"loadConfigFailed": "获取配置失败",
"loadPersonaFailed": "获取 Persona 列表失败",
"nameMissing": "存在未填写名称的 SubAgent",
"nameInvalid": "SubAgent 名称不合法:仅允许英文小写字母/数字/下划线,且需以字母开头",
"nameDuplicate": "SubAgent 名称重复:{name}",
"personaMissing": "SubAgent {name} 未选择 Persona",
"unsavedChangesNotice": "当前页面有未保存修改,请在离开前保存。",
"unsavedChangesLeaveConfirm": "当前页面有未保存修改,离开后将丢失这些更改。是否继续离开?",
"unsavedChangesReloadConfirm": "当前页面有未保存修改,刷新配置会丢失这些更改。是否继续刷新?",
"nameMissing": "存在未填写名称的子代理",
"nameInvalid": "子代理名称不合法:仅允许英文小写字母/数字/下划线,且需以字母开头",
"nameDuplicate": "子代理名称重复:{name}",
"personaMissing": "子代理 {name} 未选择 Persona",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"nameRequired": "名称必填",
"namePattern": "仅支持小写字母、数字和下划线"
},
"empty": {
"title": "未配置 SubAgent",
"title": "未配置子代理",
"subtitle": "添加一个新的子代理以开始",
"action": "创建第一个 Agent"
}

View File

@@ -0,0 +1,292 @@
.dashboard-page {
--dashboard-bg: rgb(var(--v-theme-background));
--dashboard-surface: rgb(var(--v-theme-surface));
--dashboard-text: rgb(var(--v-theme-on-surface));
--dashboard-muted: rgba(var(--v-theme-on-surface), 0.68);
--dashboard-subtle: rgba(var(--v-theme-on-surface), 0.56);
--dashboard-border: rgba(var(--v-theme-on-surface), 0.1);
--dashboard-border-strong: rgba(var(--v-theme-on-surface), 0.14);
--dashboard-soft: rgba(var(--v-theme-primary), 0.08);
--dashboard-soft-strong: rgba(var(--v-theme-primary), 0.14);
min-height: 100%;
background: var(--dashboard-bg);
}
.dashboard-page.is-dark {
--dashboard-border: rgba(var(--v-theme-on-surface), 0.14);
--dashboard-border-strong: rgba(var(--v-theme-on-surface), 0.18);
--dashboard-soft: rgba(var(--v-theme-primary), 0.12);
--dashboard-soft-strong: rgba(var(--v-theme-primary), 0.18);
}
.dashboard-shell {
max-width: 1560px;
margin: 0 auto;
color: var(--dashboard-text);
font-family: "SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
margin-bottom: 24px;
}
.dashboard-header-main {
min-width: 0;
}
.dashboard-eyebrow {
margin-bottom: 8px;
color: var(--dashboard-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dashboard-title {
margin: 0;
font-size: clamp(32px, 4vw, 44px);
line-height: 1.04;
font-weight: 700;
letter-spacing: -0.04em;
}
.dashboard-subtitle {
margin: 10px 0 0;
color: var(--dashboard-muted);
font-size: 15px;
line-height: 1.6;
max-width: 860px;
}
.dashboard-header-meta,
.dashboard-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.dashboard-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid var(--dashboard-border);
border-radius: 999px;
background: var(--dashboard-surface);
color: var(--dashboard-muted);
font-size: 13px;
}
.dashboard-overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.dashboard-card {
border: 1px solid var(--dashboard-border);
border-radius: 16px;
background: var(--dashboard-surface);
}
.dashboard-card--padded {
padding: 22px;
}
.dashboard-overview-card {
padding: 20px 20px 18px;
}
.dashboard-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 12px;
background: var(--dashboard-soft);
color: rgb(var(--v-theme-primary));
}
.dashboard-card-label {
margin-top: 12px;
color: var(--dashboard-muted);
font-size: 13px;
font-weight: 500;
}
.dashboard-card-value {
margin-top: 8px;
font-size: clamp(24px, 2vw, 34px);
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
}
.dashboard-card-note {
margin-top: 8px;
color: var(--dashboard-subtle);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.dashboard-section-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
margin-bottom: 16px;
}
.dashboard-section-title {
font-size: 19px;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.3;
overflow-wrap: anywhere;
}
.dashboard-section-subtitle {
margin-top: 6px;
color: var(--dashboard-muted);
font-size: 13px;
line-height: 1.6;
}
.dashboard-section-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.dashboard-split-grid {
display: grid;
grid-template-columns: 1.6fr 0.9fr;
gap: 20px;
align-items: stretch;
}
.dashboard-split-grid > * {
min-width: 0;
width: 100%;
}
.dashboard-side-column {
display: grid;
gap: 20px;
align-content: start;
min-width: 0;
}
.dashboard-meta-list {
display: grid;
gap: 10px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--dashboard-border);
}
.dashboard-meta-row,
.dashboard-list-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
min-width: 0;
}
.dashboard-meta-row {
color: var(--dashboard-muted);
font-size: 14px;
}
.dashboard-list {
display: grid;
gap: 12px;
}
.dashboard-list-row {
padding: 12px 0;
border-bottom: 1px solid var(--dashboard-border);
font-size: 14px;
}
.dashboard-list-row:last-child {
border-bottom: 0;
}
.dashboard-list-label {
color: var(--dashboard-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-empty {
color: var(--dashboard-muted);
font-size: 14px;
}
.dashboard-scrollable {
max-height: 360px;
overflow-y: auto;
padding-right: 6px;
}
.dashboard-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.dashboard-form-grid--single {
grid-template-columns: 1fr;
}
.dashboard-dialog-card {
border: 1px solid var(--dashboard-border);
border-radius: 18px;
}
@media (max-width: 1280px) {
.dashboard-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1080px) {
.dashboard-split-grid,
.dashboard-form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.dashboard-header,
.dashboard-section-head {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 640px) {
.dashboard-overview-grid {
grid-template-columns: 1fr;
}
.dashboard-card--padded,
.dashboard-overview-card {
padding: 18px;
}
}

View File

@@ -71,30 +71,60 @@
class="elevation-0" :items-per-page="pagination.page_size"
:items-per-page-options="pageSizeOptions" show-select return-object
:disabled="loading" @update:options="handleTableOptions">
<template v-slot:item.title="{ item }">
<div class="d-flex align-center">
<span>{{ item.title || tm('status.noTitle') }}</span>
<template v-slot:header.umo_source>
<div class="umo-header-cell">
<span>{{ tm('table.headers.umo') }}</span>
<v-btn-toggle
v-model="umoDisplayMode"
mandatory
density="compact"
divided
variant="outlined"
class="umo-header-toggle"
>
<v-btn value="parsed" size="x-small">
{{ tm('table.umoDisplay.parsed') }}
</v-btn>
<v-btn value="raw" size="x-small">
{{ tm('table.umoDisplay.raw') }}
</v-btn>
</v-btn-toggle>
</div>
</template>
<template v-slot:item.platform="{ item }">
<v-chip size="small" label>
{{ item.sessionInfo.platform || tm('status.unknown') }}
</v-chip>
<template v-slot:item.title="{ item }">
<div class="conversation-title-cell">
<span class="conversation-title-text">{{ item.title || tm('status.noTitle') }}</span>
<span class="conversation-title-meta">{{ item.cid || tm('status.unknown') }}</span>
</div>
</template>
<template v-slot:item.messageType="{ item }">
<v-chip size="small" label>
{{ getMessageTypeDisplay(item.sessionInfo.messageType) }}
</v-chip>
</template>
<template v-slot:item.cid="{ item }">
<span class="text-truncate">{{ item.cid || tm('status.unknown') }}</span>
</template>
<template v-slot:item.sessionId="{ item }">
<span>{{ item.sessionInfo.sessionId || tm('status.unknown') }}</span>
<template v-slot:item.umo_source="{ item }">
<div class="umo-source-cell">
<div class="umo-source-content">
<template v-if="umoDisplayMode === 'parsed'">
<v-chip size="x-small" label>
{{ item.sessionInfo.platform || tm('status.unknown') }}
</v-chip>
<span class="umo-separator">:</span>
<v-chip size="x-small" label>
{{ getMessageTypeDisplay(item.sessionInfo.messageType) }}
</v-chip>
<span class="umo-separator">:</span>
<span class="umo-session-id">{{ item.sessionInfo.sessionId || tm('status.unknown') }}</span>
</template>
<span v-else class="umo-raw-text">{{ item.user_id || tm('status.unknown') }}</span>
</div>
<v-btn
icon
variant="plain"
size="x-small"
class="umo-copy-button"
@click.stop="copyUmoSource(item)"
>
<v-icon size="16">mdi-content-copy</v-icon>
</v-btn>
</div>
</template>
<template v-slot:item.created_at="{ item }">
@@ -415,6 +445,7 @@ export default {
editedHistory: '',
savingHistory: false,
monacoEditor: null,
umoDisplayMode: 'parsed',
commonStore: useCommonStore()
}
@@ -445,17 +476,8 @@ export default {
// 动态表头
tableHeaders() {
return [
{ title: this.tm('table.headers.title'), key: 'title', sortable: true },
{ title: this.tm('table.headers.cid'), key: 'cid', sortable: true, width: '100px' },
{
title: this.tm('table.headers.umo'),
align: 'center',
children: [
{ title: this.tm('table.headers.platform'), key: 'platform', sortable: true, width: '120px' },
{ title: this.tm('table.headers.type'), key: 'messageType', sortable: true, width: '100px' },
{ title: this.tm('table.headers.sessionId'), key: 'sessionId', sortable: true, width: '100px' },
],
},
{ title: this.tm('table.headers.title'), key: 'title', sortable: true, minWidth: '80px', width: '200px' },
{ title: this.tm('table.headers.umo'), key: 'umo_source', sortable: false, minWidth: '280px', width: '360px' },
{ title: this.tm('table.headers.createdAt'), key: 'created_at', sortable: true, width: '180px' },
{ title: this.tm('table.headers.updatedAt'), key: 'updated_at', sortable: true, width: '180px' },
{ title: this.tm('table.headers.actions'), key: 'actions', sortable: false, align: 'center' }
@@ -590,6 +612,30 @@ export default {
return typeMap[messageType] || typeMap.default;
},
formatUmoSource(item) {
if (!item?.sessionInfo) {
return item?.user_id || this.tm('status.unknown');
}
if (this.umoDisplayMode === 'raw') {
return item.user_id || this.tm('status.unknown');
}
const platform = item.sessionInfo.platform || this.tm('status.unknown');
const messageType = this.getMessageTypeDisplay(item.sessionInfo.messageType);
const sessionId = item.sessionInfo.sessionId || this.tm('status.unknown');
return `${platform}:${messageType}:${sessionId}`;
},
async copyUmoSource(item) {
try {
await navigator.clipboard.writeText(this.formatUmoSource(item));
this.showSuccessMessage(this.tm('messages.copySuccess'));
} catch (error) {
this.showErrorMessage(this.tm('messages.copyError'));
}
},
// 获取对话列表
fetchConversations: (() => {
let controller = new AbortController();
@@ -1136,6 +1182,72 @@ export default {
text-overflow: ellipsis;
}
.conversation-title-cell {
padding: 6px 0px;
min-width: 100px;
max-width: 145px;
}
.conversation-title-text {
display: inline-block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-title-meta {
display: block;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.umo-header-cell {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.umo-header-toggle {
flex-shrink: 0;
}
.umo-source-cell {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.umo-source-content {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.umo-separator {
color: rgba(var(--v-theme-on-surface), 0.5);
flex-shrink: 0;
}
.umo-session-id,
.umo-raw-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.umo-copy-button {
flex-shrink: 0;
}
/* 动画 */
@keyframes fadeIn {
from {

View File

@@ -1,112 +1,231 @@
<template>
<div class="cron-page">
<div class="d-flex align-center justify-space-between mb-4">
<div>
<div class="d-flex align-center" style="gap: 8px;">
<h2 class="text-h5 font-weight-bold">{{ tm('page.title') }}</h2>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>{{ tm('page.beta') }}</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis">
{{ tm('page.subtitle') }}
<span v-if="proactivePlatforms.length">
{{ tm('page.proactive.supported', { platforms: proactivePlatformText }) }}
</span>
<span v-else>{{ tm('page.proactive.unsupported') }}</span>
</div>
</div>
<div class="d-flex align-center" style="gap: 8px;">
<v-btn variant="tonal" color="primary" @click="openCreate">{{ tm('actions.create') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="loading" @click="loadJobs">{{ tm('actions.refresh') }}</v-btn>
</div>
</div>
<v-card class="rounded-lg" variant="flat">
<v-card-text>
<div class="d-flex align-center justify-space-between mb-3">
<div class="text-subtitle-1 font-weight-bold">{{ tm('table.title') }}</div>
</div>
<v-alert v-if="!jobs.length && !loading" type="info" variant="tonal">{{ tm('table.empty') }}</v-alert>
<v-data-table :items="jobs" :headers="headers" :loading="loading" item-key="job_id" density="comfortable"
class="elevation-0">
<template #item.name="{ item }">
<div class="py-4">
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-medium-emphasis">{{ item.description }}</div>
</div>
</template>
<template #item.type="{ item }">
<v-chip size="small" :color="item.run_once ? 'orange' : 'primary'" variant="tonal">
{{ jobTypeLabel(item) }}
<div class="dashboard-page cron-page" :class="{ 'is-dark': isDark }">
<v-container fluid class="dashboard-shell pa-4 pa-md-6">
<div class="dashboard-header">
<div class="dashboard-header-main">
<div class="dashboard-eyebrow">{{ tm('header.eyebrow') }}</div>
<div class="d-flex align-center flex-wrap" style="gap: 8px;">
<h1 class="dashboard-title">{{ tm('page.title') }}</h1>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>
{{ tm('page.beta') }}
</v-chip>
</template>
<template #item.cron_expression="{ item }">
<div v-if="item.run_once">{{ formatTime(item.run_at) }}</div>
<div v-else>
<div>{{ item.cron_expression || tm('table.notAvailable') }}</div>
<div class="text-caption text-medium-emphasis">{{ item.timezone || tm('table.timezoneLocal') }}</div>
</div>
</template>
<template #item.session="{ item }">
<div>{{ item.session || tm('table.notAvailable') }}</div>
</template>
<template #item.next_run_time="{ item }">{{ formatTime(item.next_run_time) }}</template>
<template #item.last_run_at="{ item }">{{ formatTime(item.last_run_at) }}</template>
<template #item.note="{ item }">{{ item.note || tm('table.notAvailable') }}</template>
<template #item.actions="{ item }">
<div class="d-flex align-center flex-nowrap" style="gap: 12px; min-width: 140px;">
<v-switch v-model="item.enabled" inset density="compact" hide-details color="primary"
class="mt-0" @change="toggleJob(item)" />
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
<p class="dashboard-subtitle">
{{ tm('page.subtitle') }}
</p>
</div>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="2600">
{{ snackbar.message }}
</v-snackbar>
<div class="dashboard-header-actions">
<v-btn variant="text" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadJobs">
{{ tm('actions.refresh') }}
</v-btn>
<v-btn variant="tonal" color="primary" prepend-icon="mdi-plus" @click="openCreate">
{{ tm('actions.create') }}
</v-btn>
</div>
</div>
<v-dialog v-model="createDialog" max-width="560">
<v-card>
<v-card-title class="text-h6">{{ tm('form.title') }}</v-card-title>
<v-card-subtitle class="text-body-2 text-medium-emphasis">
{{ tm('form.chatHint') }}
</v-card-subtitle>
<v-card-text>
<v-switch v-model="newJob.run_once" :label="tm('form.runOnce')" inset color="primary" hide-details />
<v-text-field v-model="newJob.name" :label="tm('form.name')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.note" :label="tm('form.note')" variant="outlined" density="comfortable" />
<v-text-field v-if="!newJob.run_once" v-model="newJob.cron_expression" :label="tm('form.cron')"
:placeholder="tm('form.cronPlaceholder')" variant="outlined" density="comfortable" />
<v-text-field v-else v-model="newJob.run_at" :label="tm('form.runAt')" type="datetime-local"
variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.session" :label="tm('form.session')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.timezone" :label="tm('form.timezone')" variant="outlined"
density="comfortable" />
<v-switch v-model="newJob.enabled" :label="tm('form.enabled')" inset color="primary" hide-details />
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">{{ tm('actions.submit')
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<div class="dashboard-overview-grid">
<section
v-for="card in overviewCards"
:key="card.label"
class="dashboard-card dashboard-overview-card"
>
<div class="dashboard-card-icon">
<v-icon size="18">{{ card.icon }}</v-icon>
</div>
<div class="dashboard-card-label">{{ card.label }}</div>
<div class="dashboard-card-value">{{ card.value }}</div>
<div class="dashboard-card-note">{{ card.note }}</div>
</section>
</div>
<div class="dashboard-section-head">
<div>
<div class="dashboard-section-title">{{ tm('section.platforms.title') }}</div>
<div class="dashboard-section-subtitle">
{{
proactivePlatforms.length
? tm('page.proactive.supported', { platforms: proactivePlatformText })
: tm('page.proactive.unsupported')
}}
</div>
</div>
</div>
<section v-if="proactivePlatforms.length" class="platform-section">
<div class="platform-chip-wrap">
<v-chip
v-for="platform in proactivePlatforms"
:key="platform.id"
size="small"
variant="tonal"
color="primary"
>
{{ platform.display_name || platform.name }} · {{ platform.id }}
</v-chip>
</div>
</section>
<div v-else class="dashboard-empty platform-empty">
{{ tm('page.proactive.unsupported') }}
</div>
<div class="dashboard-section-head">
<div>
<div class="dashboard-section-title">{{ tm('table.title') }}</div>
<div class="dashboard-section-subtitle">{{ tm('table.subtitle') }}</div>
</div>
</div>
<section class="task-surface">
<div v-if="loading && !jobs.length" class="state-panel">
<v-progress-circular indeterminate size="22" width="2" color="primary" />
<span>{{ tm('actions.refresh') }}...</span>
</div>
<div v-else-if="!jobs.length" class="state-panel">
<v-icon size="20" color="primary">mdi-calendar-blank-outline</v-icon>
<span>{{ tm('table.empty') }}</span>
</div>
<div v-else class="task-table-wrap">
<table class="task-table">
<colgroup>
<col class="col-name" />
<col class="col-type" />
<col class="col-cron" />
<col class="col-session" />
<col class="col-next-run" />
<col class="col-last-run" />
<col class="col-actions" />
</colgroup>
<thead>
<tr>
<th>{{ tm('table.headers.name') }}</th>
<th>{{ tm('table.headers.type') }}</th>
<th>{{ tm('table.headers.cron') }}</th>
<th>{{ tm('table.headers.session') }}</th>
<th>{{ tm('table.headers.nextRun') }}</th>
<th>{{ tm('table.headers.lastRun') }}</th>
<th class="actions-col">{{ tm('table.headers.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in sortedJobs" :key="item.job_id">
<td class="name-col">
<div class="task-name">{{ item.name || tm('table.notAvailable') }}</div>
<div class="task-subline">{{ item.description || item.job_id }}</div>
</td>
<td>
<v-chip size="small" :color="item.run_once ? 'orange' : 'primary'" variant="tonal">
{{ jobTypeLabel(item) }}
</v-chip>
</td>
<td>
<div class="task-text">{{ scheduleLabel(item) }}</div>
<div class="task-subline">{{ scheduleMeta(item) }}</div>
</td>
<td>
<div class="task-session">{{ item.session || tm('table.notAvailable') }}</div>
</td>
<td>
<div class="task-text">{{ formatTime(item.next_run_time) }}</div>
</td>
<td>
<div class="task-text">{{ formatTime(item.last_run_at) }}</div>
</td>
<td class="actions-col">
<div class="table-actions">
<v-switch
v-model="item.enabled"
inset
density="compact"
hide-details
color="primary"
class="mt-0"
@change="toggleJob(item)"
/>
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="2600">
{{ snackbar.message }}
</v-snackbar>
<v-dialog v-model="createDialog" max-width="640">
<v-card class="dashboard-dialog-card">
<v-card-title class="text-h6 pt-5 px-5">{{ tm('form.title') }}</v-card-title>
<v-card-subtitle class="px-5 text-body-2 text-medium-emphasis">
{{ tm('form.chatHint') }}
</v-card-subtitle>
<v-card-text class="px-5 pb-2">
<div class="dashboard-form-grid dashboard-form-grid--single">
<v-switch
v-model="newJob.run_once"
:label="tm('form.runOnce')"
inset
color="primary"
hide-details
/>
<v-text-field v-model="newJob.name" :label="tm('form.name')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.note" :label="tm('form.note')" variant="outlined" density="comfortable" />
<v-text-field
v-if="!newJob.run_once"
v-model="newJob.cron_expression"
:label="tm('form.cron')"
:placeholder="tm('form.cronPlaceholder')"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-else
v-model="newJob.run_at"
:label="tm('form.runAt')"
type="datetime-local"
variant="outlined"
density="comfortable"
/>
<v-text-field v-model="newJob.session" :label="tm('form.session')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.timezone" :label="tm('form.timezone')" variant="outlined" density="comfortable" />
<v-switch
v-model="newJob.enabled"
:label="tm('form.enabled')"
inset
color="primary"
hide-details
/>
</div>
</v-card-text>
<v-card-actions class="justify-end px-5 pb-5">
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">
{{ tm('actions.submit') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import axios from 'axios'
import { computed, onMounted, ref } from 'vue'
import { useTheme } from 'vuetify'
import { useModuleI18n } from '@/i18n/composables'
const { tm } = useModuleI18n('features/cron')
const theme = useTheme()
const isDark = computed(() => theme.global.current.value.dark)
const loading = ref(false)
const jobs = ref<any[]>([])
const proactivePlatforms = ref<{ id: string; name: string; display_name?: string }[]>([])
@@ -129,26 +248,59 @@ const proactivePlatformText = computed(() =>
proactivePlatforms.value.map((p) => `${p.display_name || p.name}(${p.id})`).join(' / ')
)
const headers = computed(() => [
{ title: tm('table.headers.name'), key: 'name', minWidth: '200px' },
{ title: tm('table.headers.type'), key: 'type', width: 110 },
{ title: tm('table.headers.cron'), key: 'cron_expression', minWidth: '160px' },
{ title: tm('table.headers.session'), key: 'session', minWidth: '200px' },
{ title: tm('table.headers.nextRun'), key: 'next_run_time', minWidth: '160px' },
{ title: tm('table.headers.lastRun'), key: 'last_run_at', minWidth: '160px' },
{ title: tm('table.headers.note'), key: 'note', minWidth: '220px' },
{ title: tm('table.headers.actions'), key: 'actions', width: 160, sortable: false }
const enabledJobsCount = computed(() => jobs.value.filter((job) => job.enabled).length)
const runOnceCount = computed(() => jobs.value.filter((job) => job.run_once).length)
const recurringCount = computed(() => jobs.value.filter((job) => !job.run_once).length)
const sortedJobs = computed(() =>
[...jobs.value].sort((a, b) => {
if (a.enabled !== b.enabled) {
return a.enabled ? -1 : 1
}
const nextA = parseTimeValue(a.next_run_time ?? a.run_at)
const nextB = parseTimeValue(b.next_run_time ?? b.run_at)
if (nextA !== nextB) {
if (!nextA) return 1
if (!nextB) return -1
return nextA - nextB
}
return String(a.name || '').localeCompare(String(b.name || ''))
})
)
const overviewCards = computed(() => [
{
label: tm('overview.totalTasks'),
value: String(jobs.value.length),
note: tm('overview.totalTasksNote'),
icon: 'mdi-calendar-multiple'
},
{
label: tm('overview.enabledTasks'),
value: String(enabledJobsCount.value),
note: tm('overview.enabledTasksNote'),
icon: 'mdi-check-circle-outline'
}
])
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
snackbar.value = { show: true, message, color }
}
function parseTimeValue(value: any): number {
if (!value) return 0
const ts = new Date(value).getTime()
return Number.isNaN(ts) ? 0 : ts
}
function formatTime(val: any): string {
if (!val) return tm('table.notAvailable')
try {
return new Date(val).toLocaleString()
} catch (e) {
} catch {
return String(val)
}
}
@@ -163,6 +315,20 @@ function jobTypeLabel(item: any): string {
return map[type] || tm('table.type.unknown', { type })
}
function scheduleLabel(item: any): string {
if (item.run_once) {
return formatTime(item.run_at)
}
return item.cron_expression || tm('table.notAvailable')
}
function scheduleMeta(item: any): string {
if (item.run_once) {
return tm('table.type.once')
}
return item.timezone || tm('table.timezoneLocal')
}
async function loadJobs() {
loading.value = true
try {
@@ -195,8 +361,8 @@ async function loadPlatforms() {
display_name: p?.meta?.display_name || p?.display_name
}))
}
} catch (e) {
// ignore platform fetch errors in UI; subtitle will show fallback
} catch {
// Ignore platform fetch failures and keep the fallback state.
}
}
@@ -218,7 +384,7 @@ async function deleteJob(job: any) {
const res = await axios.delete(`/api/cron/jobs/${job.job_id}`)
if (res.data.status === 'ok') {
toast(tm('messages.deleteSuccess'))
jobs.value = jobs.value.filter((j) => j.job_id !== job.job_id)
jobs.value = jobs.value.filter((item) => item.job_id !== job.job_id)
} else {
toast(res.data.message || tm('messages.deleteFailed'), 'error')
}
@@ -262,10 +428,10 @@ async function createJob() {
toast(tm('messages.runAtRequired'), 'warning')
return
}
creating.value = true
try {
const payload: any = { ...newJob.value }
const res = await axios.post('/api/cron/jobs', payload)
const res = await axios.post('/api/cron/jobs', { ...newJob.value })
if (res.data.status === 'ok') {
toast(tm('messages.createSuccess'))
createDialog.value = false
@@ -288,9 +454,153 @@ onMounted(() => {
</script>
<style scoped>
@import '@/styles/dashboard-shell.css';
.cron-page {
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
}
.task-surface {
min-width: 0;
}
.platform-section {
margin-bottom: 24px;
}
.platform-chip-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.platform-empty {
margin-bottom: 24px;
}
.task-table-wrap {
border: 1px solid var(--dashboard-border);
border-radius: 14px;
overflow: auto;
background: var(--dashboard-surface);
}
.task-table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
}
.task-table .col-name {
width: 220px;
}
.task-table .col-type {
width: 120px;
}
.task-table .col-cron {
width: 260px;
}
.task-table .col-session {
width: 340px;
}
.task-table .col-next-run,
.task-table .col-last-run {
width: 180px;
}
.task-table .col-actions {
width: 170px;
}
.task-table th {
padding: 14px 16px;
text-align: left;
background: rgba(var(--v-theme-primary), 0.04);
color: var(--dashboard-muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
border-bottom: 1px solid var(--dashboard-border);
white-space: nowrap;
}
.task-table td {
padding: 16px;
vertical-align: top;
border-bottom: 1px solid var(--dashboard-border);
}
.task-table tbody tr:last-child td {
border-bottom: 0;
}
.name-col {
min-width: 220px;
}
.task-name,
.task-text {
color: var(--dashboard-text);
font-size: 14px;
line-height: 1.5;
}
.task-name {
font-weight: 600;
}
.task-subline {
margin-top: 6px;
color: var(--dashboard-muted);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.task-session {
max-width: 340px;
color: var(--dashboard-text);
font-size: 14px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.actions-col {
width: 170px;
}
.table-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 140px;
}
.state-panel {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 220px;
border: 1px dashed var(--dashboard-border-strong);
border-radius: 14px;
color: var(--dashboard-muted);
font-size: 14px;
}
.cron-page :deep(.dashboard-overview-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 900px) {
.table-actions {
justify-content: flex-start;
}
}
</style>

View File

@@ -1,144 +1,131 @@
<template>
<div class="subagent-page">
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="d-flex align-center gap-2 mb-1">
<h2 class="text-h5 font-weight-bold">{{ tm('page.title') }}</h2>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label class="font-weight-bold">
{{ tm('page.beta') }}
</v-chip>
<div class="dashboard-page subagent-page" :class="{ 'is-dark': isDark }">
<v-container fluid class="dashboard-shell pa-4 pa-md-6">
<div class="dashboard-header">
<div class="dashboard-header-main">
<div class="dashboard-eyebrow">{{ tm('header.eyebrow') }}</div>
<div class="d-flex align-center flex-wrap" style="gap: 8px;">
<h1 class="dashboard-title">{{ tm('page.title') }}</h1>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>
{{ tm('page.beta') }}
</v-chip>
</div>
<p class="dashboard-subtitle">{{ tm('page.subtitle') }}</p>
</div>
<div class="text-body-2 text-medium-emphasis">
{{ tm('page.subtitle') }}
<div class="dashboard-header-actions">
<v-btn variant="text" color="primary" prepend-icon="mdi-refresh" :loading="loading" @click="reload">
{{ tm('actions.refresh') }}
</v-btn>
<v-btn variant="tonal" color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">
{{ tm('actions.save') }}
</v-btn>
</div>
</div>
<div class="d-flex align-center gap-2">
<v-btn
variant="text"
color="primary"
prepend-icon="mdi-refresh"
:loading="loading"
@click="reload"
>
{{ tm('actions.refresh') }}
</v-btn>
<v-btn
variant="flat"
color="primary"
prepend-icon="mdi-content-save"
:loading="saving"
@click="save"
>
{{ tm('actions.save') }}
</v-btn>
<div v-if="hasUnsavedChanges" class="unsaved-banner">
<v-icon size="18" color="warning">mdi-alert-circle-outline</v-icon>
<span>{{ tm('messages.unsavedChangesNotice') }}</span>
</div>
</div>
<!-- Global Settings Card -->
<v-card class="rounded-lg mb-6 border-thin" variant="flat" border>
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-subtitle-1 font-weight-bold mb-1">{{ tm('section.globalSettings') || 'Global Settings' }}</div>
<div class="text-caption text-medium-emphasis">
{{ mainStateDescription }}
<div class="dashboard-section-head">
<div>
<div class="dashboard-section-title">{{ tm('section.globalSettings') }}</div>
<div class="dashboard-section-subtitle">{{ mainStateDescription }}</div>
</div>
</div>
<div class="dashboard-form-grid global-settings-grid mb-5">
<div class="setting-card">
<div class="setting-card-head">
<div>
<div class="setting-title">{{ tm('switches.enable') }}</div>
<div class="setting-subtitle">{{ tm('switches.enableHint') }}</div>
</div>
<v-switch
v-model="cfg.main_enable"
color="primary"
hide-details
inset
density="comfortable"
/>
</div>
</div>
<v-divider class="my-4" />
<v-row dense>
<v-col cols="12" md="6">
<v-switch
v-model="cfg.main_enable"
:label="tm('switches.enable')"
color="primary"
hide-details
inset
density="comfortable"
>
<template #label>
<div class="d-flex flex-column">
<span class="text-body-2 font-weight-medium">{{ tm('switches.enable') }}</span>
<span class="text-caption text-medium-emphasis">{{ tm('switches.enableHint') }}</span>
</div>
</template>
</v-switch>
</v-col>
<v-col cols="12" md="6">
<div class="setting-card">
<div class="setting-card-head">
<div>
<div class="setting-title">{{ tm('switches.dedupe') }}</div>
<div class="setting-subtitle">{{ tm('switches.dedupeHint') }}</div>
</div>
<v-switch
v-model="cfg.remove_main_duplicate_tools"
:disabled="!cfg.main_enable"
:label="tm('switches.dedupe')"
color="primary"
hide-details
inset
density="comfortable"
>
<template #label>
<div class="d-flex flex-column">
<span class="text-body-2 font-weight-medium">{{ tm('switches.dedupe') }}</span>
<span class="text-caption text-medium-emphasis">{{ tm('switches.dedupeHint') }}</span>
</div>
</template>
</v-switch>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Agents List Section -->
<div class="d-flex align-center justify-space-between mb-4">
<div class="d-flex align-center gap-2">
<v-icon icon="mdi-robot" color="primary" size="small" />
<div class="text-h6 font-weight-bold">{{ tm('section.title') }}</div>
<v-chip size="small" variant="tonal" color="primary" class="ml-2">
{{ cfg.agents.length }}
</v-chip>
</div>
<v-btn
prepend-icon="mdi-plus"
color="primary"
@click="addAgent"
>
{{ tm('actions.add') }}
</v-btn>
</div>
<v-expansion-panels variant="popout" class="subagent-panels">
<v-expansion-panel
v-for="(agent, idx) in cfg.agents"
:key="agent.__key"
elevation="0"
class="border-thin mb-2 rounded-lg"
:class="{ 'border-primary': agent.enabled }"
>
<v-expansion-panel-title class="py-3">
<div class="d-flex align-center w-100 gap-4">
<!-- Status Indicator -->
<v-badge
dot
:color="agent.enabled ? 'success' : 'grey'"
inline
class="mr-2"
/>
</div>
</div>
</div>
<!-- Agent Info -->
<div class="d-flex flex-column flex-grow-1" style="min-width: 0;">
<div class="d-flex align-center gap-2">
<span class="text-subtitle-1 font-weight-bold text-truncate">
{{ agent.name || tm('cards.unnamed') }}
</span>
<div class="dashboard-section-head">
<div>
<div class="dashboard-section-title">{{ tm('section.title') }}</div>
<div class="dashboard-section-subtitle">{{ tm('section.subtitle') }}</div>
</div>
<div class="dashboard-section-actions">
<div class="dashboard-pill">
<v-icon size="16">mdi-robot-outline</v-icon>
<span>{{ cfg.agents.length }}</span>
</div>
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" @click="addAgent">
{{ tm('actions.add') }}
</v-btn>
</div>
</div>
<div v-if="cfg.agents.length === 0" class="dashboard-card dashboard-card--padded empty-card">
<div class="empty-wrap">
<v-icon icon="mdi-robot-off" size="60" class="mb-4" />
<div class="empty-title">{{ tm('empty.title') }}</div>
<div class="dashboard-empty mb-4">{{ tm('empty.subtitle') }}</div>
<v-btn color="primary" variant="tonal" @click="addAgent">
{{ tm('empty.action') }}
</v-btn>
</div>
</div>
<div v-else class="subagent-list">
<section
v-for="(agent, idx) in cfg.agents"
:key="agent.__key"
class="dashboard-card dashboard-card--padded agent-panel"
>
<div class="agent-summary">
<div class="agent-summary-main">
<div class="agent-summary-top">
<v-badge dot :color="agent.enabled ? 'success' : 'grey'" inline />
<span class="agent-name">{{ agent.name || tm('cards.unnamed') }}</span>
<v-chip size="x-small" variant="tonal" :color="agent.enabled ? 'success' : 'default'">
{{ agent.enabled ? tm('cards.statusEnabled') : tm('cards.statusDisabled') }}
</v-chip>
</div>
<div class="text-caption text-medium-emphasis text-truncate">
<div class="agent-summary-desc">
{{ agent.public_description || tm('cards.noDescription') }}
</div>
</div>
<!-- Controls (stop propagation on clicks) -->
<div class="d-flex align-center gap-2 flex-shrink-0" @click.stop>
<div class="agent-summary-actions">
<v-btn
:append-icon="isAgentExpanded(agent.__key) ? 'mdi-chevron-up' : 'mdi-chevron-down'"
variant="text"
color="default"
density="comfortable"
@click="toggleAgentExpanded(agent.__key)"
>
{{ isAgentExpanded(agent.__key) ? tm('actions.collapse') : tm('actions.expand') }}
</v-btn>
<v-switch
v-model="agent.enabled"
color="success"
@@ -155,28 +142,24 @@
/>
</div>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-divider class="mb-4" />
<v-row>
<!-- Left Column: Form -->
<v-col cols="12" md="6">
<div class="d-flex flex-column gap-4">
<v-text-field
v-model="agent.name"
:label="tm('form.nameLabel')"
:rules="[v => !!v || tm('messages.nameRequired'), v => /^[a-z][a-z0-9_]*$/.test(v) || tm('messages.namePattern')]"
variant="outlined"
density="comfortable"
hide-details="auto"
prepend-inner-icon="mdi-account"
/>
<v-expand-transition>
<div v-show="isAgentExpanded(agent.__key)" class="agent-edit-grid">
<section class="dashboard-card dashboard-card--padded inner-card">
<div class="dashboard-section-title section-mini-title">{{ tm('section.agentSetup') }}</div>
<div class="dashboard-form-grid dashboard-form-grid--single">
<v-text-field
v-model="agent.name"
:label="tm('form.nameLabel')"
:rules="[v => !!v || tm('messages.nameRequired'), v => /^[a-z][a-z0-9_]*$/.test(v) || tm('messages.namePattern')]"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<div class="d-flex flex-column gap-1">
<div class="text-caption text-medium-emphasis ml-1">{{ tm('form.providerLabel') }}</div>
<v-card variant="outlined" class="pa-0 border-thin rounded bg-transparent" style="border-color: rgba(var(--v-border-color), var(--v-border-opacity));">
<div class="pa-3">
<div class="selector-wrap">
<div class="selector-label">{{ tm('form.providerLabel') }}</div>
<div class="selector-card">
<ProviderSelector
v-model="agent.provider_id"
provider-type="chat_completion"
@@ -185,78 +168,60 @@
clearable
/>
</div>
</v-card>
</div>
</div>
<div class="d-flex flex-column gap-1">
<div class="text-caption text-medium-emphasis ml-1">{{ tm('form.personaLabel') }}</div>
<v-card variant="outlined" class="pa-0 border-thin rounded bg-transparent" style="border-color: rgba(var(--v-border-color), var(--v-border-opacity));">
<div class="pa-3">
<PersonaSelector
v-model="agent.persona_id"
/>
<div class="selector-wrap">
<div class="selector-label">{{ tm('form.personaLabel') }}</div>
<div class="selector-card">
<PersonaSelector v-model="agent.persona_id" />
</div>
</v-card>
</div>
<v-textarea
v-model="agent.public_description"
:label="tm('form.descriptionLabel')"
variant="outlined"
density="comfortable"
auto-grow
hide-details="auto"
/>
</div>
</section>
<v-textarea
v-model="agent.public_description"
:label="tm('form.descriptionLabel')"
variant="outlined"
density="comfortable"
auto-grow
hide-details="auto"
prepend-inner-icon="mdi-text"
/>
</div>
</v-col>
<!-- Right Column: Preview -->
<v-col cols="12" md="6">
<div class="h-100">
<div class="text-caption font-weight-bold text-medium-emphasis mb-2 ml-1">
{{ tm('cards.personaPreview') }}
<section class="dashboard-card dashboard-card--padded inner-card">
<div class="dashboard-section-title section-mini-title">{{ tm('cards.personaPreview') }}</div>
<div class="dashboard-section-subtitle">{{ tm('cards.previewHint') }}</div>
<div class="persona-preview-wrap">
<PersonaQuickPreview :model-value="agent.persona_id" class="h-100" />
</div>
<PersonaQuickPreview
:model-value="agent.persona_id"
class="h-100"
/>
</div>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</section>
</div>
</v-expand-transition>
</section>
</div>
<!-- Empty State -->
<div v-if="cfg.agents.length === 0" class="d-flex flex-column align-center justify-center py-12 text-medium-emphasis">
<v-icon icon="mdi-robot-off" size="64" class="mb-4 opacity-50" />
<div class="text-h6">{{ tm('empty.title') }}</div>
<div class="text-body-2 mb-4">{{ tm('empty.subtitle') }}</div>
<v-btn color="primary" variant="tonal" @click="addAgent">
{{ tm('empty.action') }}
</v-btn>
</div>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000" location="top">
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">{{ tm('actions.close') }}</v-btn>
</template>
</v-snackbar>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000" location="top">
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">{{ tm('actions.close') }}</v-btn>
</template>
</v-snackbar>
</v-container>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import axios from 'axios'
import ProviderSelector from '@/components/shared/ProviderSelector.vue'
import PersonaSelector from '@/components/shared/PersonaSelector.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useTheme } from 'vuetify'
import PersonaQuickPreview from '@/components/shared/PersonaQuickPreview.vue'
import PersonaSelector from '@/components/shared/PersonaSelector.vue'
import ProviderSelector from '@/components/shared/ProviderSelector.vue'
import { useModuleI18n } from '@/i18n/composables'
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'
type SubAgentItem = {
__key: string
name: string
persona_id: string
@@ -272,15 +237,21 @@ type SubAgentConfig = {
}
const { tm } = useModuleI18n('features/subagent')
const theme = useTheme()
const confirmDialog = useConfirmDialog()
const loading = ref(false)
const saving = ref(false)
const isDark = computed(() => theme.global.current.value.dark)
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
const expandedAgents = ref<Record<string, boolean>>({})
const initialSnapshot = ref('')
const hasLoaded = ref(false)
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
snackbar.value = { show: true, message, color }
@@ -296,37 +267,48 @@ const mainStateDescription = computed(() =>
cfg.value.main_enable ? tm('description.enabled') : tm('description.disabled')
)
const hasUnsavedChanges = computed(() => hasLoaded.value && serializeConfig(cfg.value) !== initialSnapshot.value)
function normalizeConfig(raw: any): SubAgentConfig {
const main_enable = !!raw?.main_enable
const remove_main_duplicate_tools = !!raw?.remove_main_duplicate_tools
const agentsRaw = Array.isArray(raw?.agents) ? raw.agents : []
const agents: SubAgentItem[] = agentsRaw.map((a: any, i: number) => {
const name = (a?.name ?? '').toString()
const persona_id = (a?.persona_id ?? '').toString()
const public_description = (a?.public_description ?? '').toString()
const enabled = a?.enabled !== false
const provider_id = (a?.provider_id ?? undefined) as string | undefined
return {
__key: `${Date.now()}_${i}_${Math.random().toString(16).slice(2)}`,
name,
persona_id,
public_description,
enabled,
provider_id
}
})
const agents: SubAgentItem[] = agentsRaw.map((a: any, i: number) => ({
__key: `${Date.now()}_${i}_${Math.random().toString(16).slice(2)}`,
name: (a?.name ?? '').toString(),
persona_id: (a?.persona_id ?? '').toString(),
public_description: (a?.public_description ?? '').toString(),
enabled: a?.enabled !== false,
provider_id: (a?.provider_id ?? undefined) as string | undefined
}))
return { main_enable, remove_main_duplicate_tools, agents }
}
function serializeConfig(config: SubAgentConfig): string {
return JSON.stringify({
main_enable: config.main_enable,
remove_main_duplicate_tools: config.remove_main_duplicate_tools,
agents: config.agents.map((agent) => ({
name: agent.name,
persona_id: agent.persona_id,
public_description: agent.public_description,
enabled: agent.enabled,
provider_id: agent.provider_id ?? null
}))
})
}
async function loadConfig() {
loading.value = true
try {
const res = await axios.get('/api/subagent/config')
if (res.data.status === 'ok') {
cfg.value = normalizeConfig(res.data.data)
expandedAgents.value = Object.fromEntries(cfg.value.agents.map((agent) => [agent.__key, false]))
initialSnapshot.value = serializeConfig(cfg.value)
hasLoaded.value = true
} else {
toast(res.data.message || tm('messages.loadConfigFailed'), 'error')
}
@@ -338,25 +320,39 @@ async function loadConfig() {
}
function addAgent() {
const key = `${Date.now()}_${Math.random().toString(16).slice(2)}`
cfg.value.agents.push({
__key: `${Date.now()}_${Math.random().toString(16).slice(2)}`,
__key: key,
name: '',
persona_id: '',
public_description: '',
enabled: true,
provider_id: undefined
})
expandedAgents.value[key] = false
}
function removeAgent(idx: number) {
cfg.value.agents.splice(idx, 1)
const [removed] = cfg.value.agents.splice(idx, 1)
if (removed) {
delete expandedAgents.value[removed.__key]
}
}
function isAgentExpanded(key: string): boolean {
return expandedAgents.value[key] !== false
}
function toggleAgentExpanded(key: string) {
expandedAgents.value[key] = !isAgentExpanded(key)
}
function validateBeforeSave(): boolean {
const nameRe = /^[a-z][a-z0-9_]{0,63}$/
const seen = new Set<string>()
for (const a of cfg.value.agents) {
const name = (a.name || '').trim()
for (const agent of cfg.value.agents) {
const name = (agent.name || '').trim()
if (!name) {
toast(tm('messages.nameMissing'), 'warning')
return false
@@ -370,11 +366,12 @@ function validateBeforeSave(): boolean {
return false
}
seen.add(name)
if (!a.persona_id) {
if (!agent.persona_id) {
toast(tm('messages.personaMissing', { name }), 'warning')
return false
}
}
return true
}
@@ -385,17 +382,19 @@ async function save() {
const payload = {
main_enable: cfg.value.main_enable,
remove_main_duplicate_tools: cfg.value.remove_main_duplicate_tools,
agents: cfg.value.agents.map((a) => ({
name: a.name,
persona_id: a.persona_id,
public_description: a.public_description,
enabled: a.enabled,
provider_id: a.provider_id
agents: cfg.value.agents.map((agent) => ({
name: agent.name,
persona_id: agent.persona_id,
public_description: agent.public_description,
enabled: agent.enabled,
provider_id: agent.provider_id
}))
}
const res = await axios.post('/api/subagent/config', payload)
if (res.data.status === 'ok') {
initialSnapshot.value = serializeConfig(cfg.value)
hasLoaded.value = true
toast(res.data.message || tm('messages.saveSuccess'), 'success')
} else {
toast(res.data.message || tm('messages.saveFailed'), 'error')
@@ -408,31 +407,221 @@ async function save() {
}
async function reload() {
await Promise.all([loadConfig()])
if (hasUnsavedChanges.value) {
const confirmed = await askForConfirmation(
tm('messages.unsavedChangesReloadConfirm'),
confirmDialog
)
if (!confirmed) {
return
}
}
await loadConfig()
}
async function confirmLeaveIfNeeded(): Promise<boolean> {
if (!hasUnsavedChanges.value) {
return true
}
return askForConfirmation(
tm('messages.unsavedChangesLeaveConfirm'),
confirmDialog
)
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedChanges.value) {
return
}
event.preventDefault()
event.returnValue = ''
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
reload()
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
return await confirmLeaveIfNeeded()
})
</script>
<style scoped>
@import '@/styles/dashboard-shell.css';
.subagent-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
padding-bottom: 40px;
}
.subagent-panels :deep(.v-expansion-panel-text__wrapper) {
padding: 16px;
padding-bottom: 42px;
.unsaved-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
margin-bottom: 18px;
border: 1px solid rgba(var(--v-theme-warning), 0.22);
border-radius: 12px;
background: rgba(var(--v-theme-warning), 0.08);
color: var(--dashboard-text);
font-size: 13px;
line-height: 1.5;
}
.gap-2 {
.setting-card {
border: 1px solid var(--dashboard-border);
border-radius: 14px;
padding: 18px;
background: rgba(var(--v-theme-primary), 0.02);
}
.setting-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.setting-title {
font-size: 15px;
font-weight: 600;
line-height: 1.5;
}
.setting-subtitle {
margin-top: 6px;
color: var(--dashboard-muted);
font-size: 13px;
line-height: 1.6;
}
.empty-card {
min-height: 280px;
}
.empty-wrap {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--dashboard-muted);
}
.empty-title {
font-size: 20px;
font-weight: 650;
color: var(--dashboard-text);
margin-bottom: 8px;
}
.subagent-list {
display: grid;
gap: 16px;
}
.agent-panel {
display: grid;
gap: 18px;
}
.agent-summary {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
width: 100%;
}
.agent-summary-main {
min-width: 0;
flex: 1;
}
.agent-summary-top {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.agent-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 650;
}
.agent-summary-desc {
margin-top: 8px;
color: var(--dashboard-muted);
font-size: 13px;
line-height: 1.6;
}
.agent-summary-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.agent-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.inner-card {
min-width: 0;
}
.section-mini-title {
margin-bottom: 10px;
}
.selector-wrap {
display: grid;
gap: 8px;
}
.gap-4 {
gap: 16px;
.selector-label {
color: var(--dashboard-muted);
font-size: 13px;
font-weight: 500;
}
.selector-card {
border: 1px solid var(--dashboard-border);
border-radius: 12px;
padding: 14px;
background: transparent;
}
.persona-preview-wrap {
min-height: 320px;
}
@media (max-width: 1080px) {
.agent-edit-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.setting-card-head,
.agent-summary {
flex-direction: column;
align-items: flex-start;
}
}
</style>