feat: extract MyAgent core and web console
This commit is contained in:
commit
e491b1a35b
83 files changed
+13651
No files matched your search
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#6750A4" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%2316211e'/%3E%3Cpath d='M20 20h28v8H29v4h15v8H29v4h19v8H20z' fill='%2399f6cd'/%3E%3C/svg%3E" />
|
||||
<title>MyAgent Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>需要启用 JavaScript。</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1621
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "myagent-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 5174"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "latest",
|
||||
"naive-ui": "^2.44.1",
|
||||
"vite": "latest",
|
||||
"vue": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"tailwindcss": "^4.3.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
export const API_BASE = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '');
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(API_BASE + path, options);
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
async json(path, options) {
|
||||
const res = await request(path, options);
|
||||
return await res.json();
|
||||
},
|
||||
async text(path, options) {
|
||||
const res = await request(path, options);
|
||||
return await res.text();
|
||||
},
|
||||
async raw(path, options) {
|
||||
return await request(path, options);
|
||||
}
|
||||
};
|
||||
|
||||
export function jsonOptions(body) {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
};
|
||||
}
|
||||
|
||||
export function putJsonOptions(body) {
|
||||
return {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
};
|
||||
}
|
||||
|
||||
export function textPutOptions(body) {
|
||||
return {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { apiClient, jsonOptions, putJsonOptions, textPutOptions } from './client.js';
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
export const healthApi = {
|
||||
get: () => apiClient.json('/api/health')
|
||||
};
|
||||
|
||||
export const configApi = {
|
||||
get: () => apiClient.json('/api/config'),
|
||||
metadata: () => apiClient.json('/api/config/ui-metadata'),
|
||||
save: (config) => apiClient.json('/api/config', putJsonOptions(config))
|
||||
};
|
||||
|
||||
export const modelApi = {
|
||||
list: ({ baseUrl, apiKey }) => apiClient.json('/api/openai/models', jsonOptions({ baseUrl, apiKey }))
|
||||
};
|
||||
|
||||
export const groupApi = {
|
||||
profile: (groupId) => apiClient.json(`/api/group-chat/profile?groupId=${enc(groupId)}`),
|
||||
recentEvents: (groupId, limit = 50) => apiClient.json(`/api/group-chat/recent?groupId=${enc(groupId)}&limit=${enc(limit)}`),
|
||||
recentMessages: (groupId, limit = 80) => apiClient.json(`/api/groups/${enc(groupId)}/recent?limit=${enc(limit)}`),
|
||||
conversations: (groupId) => apiClient.json(`/api/conversations?groupId=${enc(groupId)}`),
|
||||
eventsUrl: (groupId) => `/api/group-chat/events?groupId=${enc(groupId)}`
|
||||
};
|
||||
|
||||
export const contextApi = {
|
||||
get: (groupId) => apiClient.text(`/api/groups/${enc(groupId)}/context`),
|
||||
save: (groupId, text) => apiClient.json(`/api/groups/${enc(groupId)}/context`, textPutOptions(text)),
|
||||
reset: (groupId) => apiClient.json(`/api/groups/${enc(groupId)}/context/reset`, { method: 'POST' }),
|
||||
clearAll: (groupId) => apiClient.json(`/api/groups/${enc(groupId)}/context/clear-all`, { method: 'POST' }),
|
||||
refresh: (groupId) => apiClient.json(`/api/groups/${enc(groupId)}/context/refresh`, { method: 'POST' }),
|
||||
parts: (groupId, userId) => apiClient.json(`/api/groups/${enc(groupId)}/context-parts?userId=${enc(userId || '')}`),
|
||||
heavyMemory: (groupId, limit = 50) => apiClient.json(`/api/groups/${enc(groupId)}/heavy-memory?limit=${enc(limit)}`),
|
||||
searchHeavyMemory: (groupId, query, limit = 8) => apiClient.json(`/api/groups/${enc(groupId)}/heavy-memory/search?q=${enc(query || '')}&limit=${enc(limit)}`),
|
||||
lightRecallCache: (groupId) => apiClient.text(`/api/groups/${enc(groupId)}/light-recall-cache`)
|
||||
};
|
||||
|
||||
export const memberMemoryApi = {
|
||||
get: (groupId, userId) => apiClient.text(`/api/groups/${enc(groupId)}/members/${enc(userId)}/memory`),
|
||||
save: (groupId, userId, text) => apiClient.json(`/api/groups/${enc(groupId)}/members/${enc(userId)}/memory`, textPutOptions(text)),
|
||||
reset: (groupId, userId) => apiClient.json(`/api/groups/${enc(groupId)}/members/${enc(userId)}/memory/reset`, { method: 'POST' }),
|
||||
remove: (groupId, userId) => apiClient.json(`/api/groups/${enc(groupId)}/members/${enc(userId)}/memory`, { method: 'DELETE' }),
|
||||
list: (groupId) => apiClient.json(`/api/groups/${enc(groupId)}/members`)
|
||||
};
|
||||
|
||||
export const taskApi = {
|
||||
list: () => apiClient.json('/api/tasks'),
|
||||
get: (taskId) => apiClient.json(`/api/tasks/${enc(taskId)}`)
|
||||
};
|
||||
|
||||
export const mediaApi = {
|
||||
sources: () => apiClient.json('/api/media/sources'),
|
||||
search: (request) => apiClient.json('/api/media/search', jsonOptions(request)),
|
||||
download: (request) => apiClient.json('/api/media/download', jsonOptions(request))
|
||||
};
|
||||
|
||||
export const toolApi = {
|
||||
catalog: () => apiClient.json('/api/tools/catalog')
|
||||
};
|
||||
|
||||
export const workflowApi = {
|
||||
metadata: () => apiClient.json('/api/workflow/metadata')
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
<script>
|
||||
import { inject } from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'ConfigPage',
|
||||
setup() {
|
||||
return inject('appCtx');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-grid cols="1 l:12" responsive="screen" :x-gap="18" :y-gap="18">
|
||||
<n-gi span="1 l:8">
|
||||
<n-card :bordered="false" class="panel-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<n-h2 class="card-heading">配置</n-h2>
|
||||
<n-text depth="3">表单结构由后端 <n-code code="/api/config/ui-metadata" /> 提供;前端只负责渲染和保存。</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button secondary :loading="modelsLoading" @click="loadModels">{{ modelsLoading ? '拉取中...' : '拉取模型' }}</n-button>
|
||||
<n-button type="primary" :loading="loading" @click="saveConfig">保存配置</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-alert v-if="modelsError" type="error" class="section-gap">{{ modelsError }}</n-alert>
|
||||
|
||||
<n-space v-if="configMetadata?.sections?.length" vertical size="large">
|
||||
<n-card v-for="section in configMetadata.sections" :key="section.key" embedded size="small">
|
||||
<template #header>
|
||||
<div>
|
||||
<strong>{{ section.title }}</strong>
|
||||
<div><n-text depth="3">{{ section.description }}</n-text></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16" :y-gap="14">
|
||||
<n-gi v-for="field in section.fields" :key="field.path" :span="field.full ? '1 m:2' : 1">
|
||||
<n-form-item :label="field.type === 'checkbox' ? undefined : field.label" :show-feedback="false">
|
||||
<n-checkbox
|
||||
v-if="field.type === 'checkbox'"
|
||||
:checked="!!getConfigValue(field.path)"
|
||||
@update:checked="setConfigValue(field.path, $event, field)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</n-checkbox>
|
||||
|
||||
<n-select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="getConfigValue(field.path)"
|
||||
:options="(field.options || []).map(opt => ({ label: opt.label, value: opt.value }))"
|
||||
@update:value="setConfigValue(field.path, $event, field)"
|
||||
/>
|
||||
|
||||
<n-select
|
||||
v-else-if="field.type === 'model'"
|
||||
:value="getConfigValue(field.path)"
|
||||
:options="modelOptions(field).map(m => ({ label: m, value: m }))"
|
||||
:placeholder="modelOptions(field).length ? '选择模型' : '请先拉取模型列表'"
|
||||
filterable
|
||||
tag
|
||||
@update:value="setConfigValue(field.path, $event, field)"
|
||||
/>
|
||||
|
||||
<n-input-number
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="Number(getConfigValue(field.path) || 0)"
|
||||
:min="field.min ?? undefined"
|
||||
:max="field.max ?? undefined"
|
||||
@update:value="setConfigValue(field.path, $event, field)"
|
||||
/>
|
||||
|
||||
<n-input
|
||||
v-else
|
||||
:type="field.type === 'secret' ? 'password' : 'text'"
|
||||
:placeholder="field.type === 'secret' && secretConfigured(field) ? '已配置;留空保持,输入新值可替换' : field.placeholder || (field.type === 'secret' ? '尚未配置' : '')"
|
||||
:value="fieldValueForInput(field)"
|
||||
show-password-on="click"
|
||||
@update:value="setConfigValue(field.path, $event, field)"
|
||||
>
|
||||
<template v-if="field.type === 'secret' && secretConfigured(field)" #suffix>
|
||||
<span class="secret-state">已配置</span>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</n-space>
|
||||
|
||||
<n-empty v-else description="配置表单元数据未加载">
|
||||
<template #extra>请确认后端 /api/config/ui-metadata 正常返回。</template>
|
||||
</n-empty>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi span="1 l:4">
|
||||
<n-space vertical size="large">
|
||||
<n-card :bordered="false" class="panel-card">
|
||||
<template #header>当前配置</template>
|
||||
<n-text depth="3">{{ config?.configPath }}</n-text>
|
||||
<n-code :code="JSON.stringify(config, null, 2)" language="json" word-wrap class="code-block" />
|
||||
</n-card>
|
||||
|
||||
<n-card :bordered="false" class="panel-card">
|
||||
<template #header>工具目录</template>
|
||||
<n-list hoverable>
|
||||
<n-list-item v-for="tool in toolCatalog" :key="tool.name">
|
||||
<n-thing :title="tool.name" :description="`${tool.executor} · evidence=${tool.returnsEvidence} · attachments=${tool.returnsAttachments}`">
|
||||
{{ tool.purpose }}
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel-card { box-shadow: 0 10px 30px rgba(31, 34, 37, .06); }
|
||||
.card-heading { margin: 0; font-size: 20px; }
|
||||
.section-gap { margin-bottom: 16px; }
|
||||
.code-block { display: block; margin-top: 12px; max-height: 520px; overflow: auto; }
|
||||
.secret-state { color: #278662; font-size: 12px; font-weight: 650; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script>
|
||||
import { inject } from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'ContextPage',
|
||||
setup() {
|
||||
return inject('appCtx');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-grid cols="1 l:12" responsive="screen" :x-gap="18" :y-gap="18">
|
||||
<n-gi span="1 l:8">
|
||||
<n-card :bordered="false" class="panel-card editor-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<n-h2 class="card-heading">机器人 / 群设定</n-h2>
|
||||
<n-text depth="3">群级 context.md:机器人设定、群长期信息、群规则、活跃话题和最近摘要。</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button secondary @click="loadContext">重载</n-button>
|
||||
<n-button secondary :loading="loading" @click="refreshContextMemory">自动整理</n-button>
|
||||
<n-button tertiary type="warning" :disabled="loading" @click="resetContext">重置 context.md</n-button>
|
||||
<n-button tertiary type="error" :disabled="loading" @click="clearAllContext">一键清空全部</n-button>
|
||||
<n-button type="primary" :loading="loading" @click="saveContext">保存群设定</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-alert type="warning" :bordered="false" class="danger-alert">
|
||||
“一键清空全部”会删除本群 context.md、群友轻记忆、重记忆、重记忆备份、召回暂存和线程上下文,并重新生成默认 context.md。
|
||||
</n-alert>
|
||||
<n-tag v-if="contextParts?.groupContextPath" class="path-tag" round>{{ contextParts.groupContextPath }}</n-tag>
|
||||
<n-input
|
||||
v-model:value="contextText"
|
||||
type="textarea"
|
||||
class="context-editor"
|
||||
placeholder="群设定 Markdown"
|
||||
:autosize="{ minRows: 24 }"
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi span="1 l:4">
|
||||
<n-card :bordered="false" class="panel-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<strong>上下文注入预览</strong>
|
||||
<div><n-text depth="3">当前回复会合并群设定 + 当前群友记忆。</n-text></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-tabs type="segment" animated>
|
||||
<n-tab-pane name="preview" tab="注入预览">
|
||||
<n-space v-if="contextParts" vertical size="medium">
|
||||
<n-card embedded size="small"><template #header>机器人/群设定</template>{{ contextParts.botProfile }}</n-card>
|
||||
<n-card embedded size="small"><template #header>当前话题窗口 / 注意力(短)</template>{{ contextParts.questionContext }}</n-card>
|
||||
<n-card v-if="contextParts.memberMemory && contextParts.memberMemory !== '无'" embedded size="small">
|
||||
<template #header>轻记忆:{{ form.userId }}</template>
|
||||
{{ contextParts.memberMemory }}
|
||||
</n-card>
|
||||
<n-card embedded size="small">
|
||||
<template #header>重记忆策略</template>
|
||||
<n-text depth="3">
|
||||
轻记忆只保留本群讨论过的事情大纲、稳定偏好和搜索消歧。重记忆由 Planner 的语义权重按需触发,召回片段仅保存在当前 run 的 ArtifactJournal 中,不跨用户或任务共享。
|
||||
</n-text>
|
||||
</n-card>
|
||||
</n-space>
|
||||
<n-empty v-else description="暂无上下文预览" />
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="heavy" tab="重记忆">
|
||||
<n-space vertical size="medium">
|
||||
<n-alert type="info" :bordered="false">
|
||||
轻记忆只存大纲;重记忆保存精确时间节点、话题、模型总结和原始对话。召回结果只进入当前任务上下文,任务结束后不会作为群级临时缓存注入下一轮。
|
||||
</n-alert>
|
||||
|
||||
<n-space>
|
||||
<n-input v-model:value="heavyMemoryQuery" clearable placeholder="按关键词搜索重记忆,例如:PDF、图片下载、上次方案" @keyup.enter="searchHeavyMemory" />
|
||||
<n-button type="primary" :loading="loading" @click="searchHeavyMemory">搜索</n-button>
|
||||
<n-button secondary @click="loadHeavyMemory">刷新列表</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="heavyMemory?.path" embedded size="small">
|
||||
<template #header>存储位置</template>
|
||||
<n-space vertical size="small">
|
||||
<n-tag round>重记忆:{{ heavyMemory.path }}</n-tag>
|
||||
<n-tag round>备份:{{ heavyMemory.backupDir }}</n-tag>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-divider>搜索结果</n-divider>
|
||||
<n-empty v-if="!(heavyMemorySearch.items || []).length" description="暂无搜索结果" />
|
||||
<n-collapse v-else accordion>
|
||||
<n-collapse-item v-for="item in heavyMemorySearch.items" :key="item.id" :title="item.topic" :name="item.id">
|
||||
<n-space vertical size="small">
|
||||
<n-text depth="3">{{ new Date(item.startAt).toLocaleString() }} ~ {{ new Date(item.endAt).toLocaleString() }} · 重要度 {{ item.importance }}</n-text>
|
||||
<div><n-tag v-for="kw in item.keywords" :key="kw" size="small" round class="kw-tag">{{ kw }}</n-tag></div>
|
||||
<p>{{ item.summary }}</p>
|
||||
<pre class="memory-pre">{{ item.rawDialoguePreview }}</pre>
|
||||
</n-space>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
|
||||
<n-divider>最近重记忆</n-divider>
|
||||
<n-empty v-if="!(heavyMemory.items || []).length" description="暂无重记忆归档" />
|
||||
<n-list v-else hoverable>
|
||||
<n-list-item v-for="item in heavyMemory.items" :key="item.id">
|
||||
<n-thing :title="item.topic">
|
||||
<template #description>
|
||||
{{ new Date(item.startAt).toLocaleString() }} ~ {{ new Date(item.endAt).toLocaleString() }} · {{ (item.participants || []).join('、') || '无参与者' }}
|
||||
</template>
|
||||
<n-ellipsis :line-clamp="3">{{ item.summary }}</n-ellipsis>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="members" tab="群友轻记忆">
|
||||
<n-list v-if="memberMemories.length" hoverable clickable>
|
||||
<n-list-item v-for="m in memberMemories" :key="m.userId" :class="{ active: String(m.userId) === String(form.userId) }" @click="form.userId = m.userId; loadContextParts()">
|
||||
<n-thing :title="getMember(m.userId).name || m.userId">
|
||||
<template #description>{{ m.userId }} · {{ Math.round((m.bytes || 0) / 1024) }}KB · {{ new Date(m.updatedAt).toLocaleString() }}</template>
|
||||
<n-ellipsis :line-clamp="2">{{ m.preview }}</n-ellipsis>
|
||||
<template #action>
|
||||
<n-button size="small" type="error" tertiary :disabled="loading" @click.stop="deleteMemberMemory(m.userId)">删除</n-button>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<n-empty v-else description="暂无群友轻记忆文件" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel-card { box-shadow: 0 10px 30px rgba(31, 34, 37, .06); }
|
||||
.card-heading { margin: 0; font-size: 20px; }
|
||||
.danger-alert { margin-bottom: 12px; }
|
||||
.path-tag { margin-bottom: 12px; max-width: 100%; }
|
||||
.context-editor { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.memory-card { max-height: 360px; overflow: hidden; }
|
||||
.memory-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, .04);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.kw-tag { margin: 0 6px 6px 0; }
|
||||
.active { background: rgba(24, 160, 88, .08); }
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<script>
|
||||
import { inject } from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'GroupPage',
|
||||
setup() {
|
||||
return inject('appCtx');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-grid cols="1 m:12" responsive="screen" :x-gap="18" :y-gap="18" class="group-page">
|
||||
<n-gi span="1 m:7">
|
||||
<n-card class="panel-card chat-card" :bordered="false">
|
||||
<template #header>
|
||||
<n-space align="center" :wrap="false">
|
||||
<n-avatar v-if="groupProfile?.avatarUrl" round :src="groupProfile.avatarUrl" :size="46" object-fit="cover" />
|
||||
<n-avatar v-else round :size="46" color="#18a058">群</n-avatar>
|
||||
<div class="group-title-block">
|
||||
<n-space align="center" size="small">
|
||||
<n-h2 class="card-heading">{{ groupProfile?.groupName || currentGroupLabel }}</n-h2>
|
||||
<n-select
|
||||
size="small"
|
||||
class="group-switcher"
|
||||
:value="String(form.groupId)"
|
||||
:options="groupOptions"
|
||||
tag
|
||||
placeholder="选择群"
|
||||
@update:value="switchGroup"
|
||||
/>
|
||||
</n-space>
|
||||
<n-text depth="3">群号 {{ groupProfile?.groupId || form.groupId }}<template v-if="groupProfile?.memberCount"> · {{ groupProfile.memberCount }} 人</template> · 记忆/上下文按群隔离</n-text>
|
||||
</div>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space align="center" class="header-actions">
|
||||
<n-tooltip trigger="hover"><template #trigger><n-tag round type="info" class="route-tag">{{ routeSummary }}</n-tag></template>{{ routeSummary }}</n-tooltip>
|
||||
<n-button secondary :disabled="loading || !chat.messages.length" @click="clearChat">清空</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<div class="chat-card-body">
|
||||
<div class="messages">
|
||||
<n-empty v-if="!chat.messages.length" description="等待群聊事件">
|
||||
<template #extra>
|
||||
<div class="empty-copy">事件流连接后,群消息、回复和 Agent 执行状态会在这里实时出现。</div>
|
||||
</template>
|
||||
</n-empty>
|
||||
|
||||
<n-space v-else vertical size="large" class="message-stack">
|
||||
<article v-for="(m, i) in chat.messages" :key="i" class="message-row" :class="messageClasses(m)" @click="selectMessage(m)">
|
||||
<n-avatar v-if="m.member?.avatarUrl" round :src="m.member.avatarUrl" object-fit="cover" />
|
||||
<n-avatar v-else round color="#2080f0">{{ avatarText(m.userId) }}</n-avatar>
|
||||
<n-card class="message-bubble" :class="m.role" size="small" :bordered="false">
|
||||
<n-space vertical size="small">
|
||||
<n-text depth="3" class="message-meta">
|
||||
{{ m.role === 'assistant' ? (configForm.bot.botDisplayName || '莲') : (m.member?.name || m.userId) }} · {{ m.userId }} · {{ m.at.toLocaleString() }}
|
||||
<template v-if="m.role === 'assistant' && (m.totalElapsedMs ?? m.elapsedMs) !== undefined"> · 耗时 {{ formatMs(m.totalElapsedMs ?? m.elapsedMs) }}</template>
|
||||
</n-text>
|
||||
|
||||
<div v-if="!m.thinking && m.segments?.length" class="segments">
|
||||
<n-p v-for="(seg, si) in m.segments" :key="si">{{ seg }}</n-p>
|
||||
</div>
|
||||
<n-p v-else-if="!m.thinking" class="message-text">{{ m.text }}</n-p>
|
||||
<n-space v-else align="center" size="small" class="thinking"><n-spin size="small" />{{ m.text }}</n-space>
|
||||
|
||||
<n-space v-if="!m.thinking" size="small">
|
||||
<n-button size="tiny" text @click.stop="copyText(m.text)">复制</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-text v-if="m.meta?.route" depth="3" italic>{{ m.meta.route.mode }} · {{ m.meta.route.webPolicy }} · {{ m.meta.route.reason }}</n-text>
|
||||
|
||||
<n-image-group v-if="m.attachments?.length">
|
||||
<n-space>
|
||||
<a v-for="att in m.attachments" :key="att.mediaId || att.sourceUrl" :href="mediaUrl(att.url || att.sourceUrl)" target="_blank" class="attachment-link" @click.stop>
|
||||
<n-image :src="mediaUrl(att.url || att.sourceUrl)" width="120" object-fit="cover" preview-disabled />
|
||||
<small>{{ att.title || att.mediaId || '图片' }}</small>
|
||||
</a>
|
||||
</n-space>
|
||||
</n-image-group>
|
||||
|
||||
<n-space v-if="m.meta?.notes?.length" vertical size="small">
|
||||
<n-alert v-for="(note, ni) in m.meta.notes" :key="ni" type="info" :show-icon="false">{{ note }}</n-alert>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</article>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<n-space justify="end" class="composer-actions">
|
||||
<n-tag v-if="chat.rawResult?.eventStream" size="small" :type="chat.rawResult.eventStream === 'reconnecting' ? 'warning' : 'success'">{{ chat.rawResult.eventStream }}</n-tag>
|
||||
<n-button text size="small" @click="loadGroupRecent">同步历史</n-button>
|
||||
<n-button text size="small" @click="connectGroupEvents">重连</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi span="1 m:5">
|
||||
<n-card class="panel-card observability-card" :bordered="false">
|
||||
<template #header>
|
||||
<div>
|
||||
<n-h2 class="card-heading">AI 调用面板</n-h2>
|
||||
<n-text depth="3">点击左侧任意群消息,查看对应回答的流程。</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra><n-button secondary @click="loadConversations">刷新</n-button></template>
|
||||
|
||||
<n-grid cols="2 s:4" responsive="screen" :x-gap="8" :y-gap="8" class="stats-grid">
|
||||
<n-gi><div class="metric-tile"><span>最终状态</span><strong>{{ statusText(activeStatus) }}</strong></div></n-gi>
|
||||
<n-gi><div class="metric-tile"><span>总耗时</span><strong>{{ formatMs(traceStats.totalMs) }}</strong></div></n-gi>
|
||||
<n-gi><div class="metric-tile"><span>首 token</span><strong>{{ formatMs(traceStats.firstTokenMs) }}</strong></div></n-gi>
|
||||
<n-gi><div class="metric-tile"><span>缓存</span><strong>{{ traceStats.cacheHit ? 'HIT' : 'MISS' }}</strong></div></n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-space class="flow-tags">
|
||||
<n-tag type="info">流程状态:{{ statusText(activeTopology?.overallStatus || activeStatus) }}</n-tag>
|
||||
<n-tag :type="(activeTopology?.runningKeys?.length || activeTopology?.runningKey) ? 'warning' : 'default'">运行中:{{ activeTopology?.runningKeys?.join('、') || activeTopology?.runningKey || '无' }}</n-tag>
|
||||
</n-space>
|
||||
|
||||
<n-space v-if="activeTopology || latestConversation" vertical size="large">
|
||||
<n-card embedded size="small">
|
||||
<n-text depth="3">{{ selectedMessage ? '当前选中消息' : '默认显示最新对话' }}</n-text>
|
||||
<n-p><b>用户:</b>{{ activeQuestion || '无' }}</n-p>
|
||||
<n-p v-if="activeAnswer"><b>{{ configForm.bot.botDisplayName || '莲' }}:</b>{{ activeAnswer }}</n-p>
|
||||
<n-tag size="small">{{ latestConversation?.status || 'running' }} / {{ activeStatus }}</n-tag>
|
||||
<n-text depth="3" class="thread-id">{{ activeThreadId }}</n-text>
|
||||
</n-card>
|
||||
|
||||
<n-card embedded size="small" class="topology-card">
|
||||
<template #header>Agent 实时拓扑</template>
|
||||
<template #header-extra>
|
||||
<n-space align="center" size="small">
|
||||
<n-tag :type="activeTopology?.runningKey ? 'warning' : 'success'" round>
|
||||
{{ activeTopology?.runningKey ? '实时运行中' : statusText(activeTopology?.overallStatus || activeStatus) }}
|
||||
</n-tag>
|
||||
<n-text depth="3" class="topology-updated">更新 {{ activeTopologyUpdatedAt }}</n-text>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<div class="topology-board">
|
||||
<template v-for="(step, index) in requiredTopologyNodes" :key="step.key">
|
||||
<section class="topology-step" :class="[step.status, { running: (activeTopology?.runningKeys || [activeTopology?.runningKey]).includes(step.key) }]">
|
||||
<div class="step-orb">
|
||||
<span class="step-index">{{ index + 1 }}</span>
|
||||
<span v-if="(activeTopology?.runningKeys || [activeTopology?.runningKey]).includes(step.key)" class="live-ring"></span>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<n-space justify="space-between" align="start" :wrap="false">
|
||||
<div>
|
||||
<b>{{ step.name }}</b>
|
||||
<p>{{ step.agent || '-' }}</p>
|
||||
</div>
|
||||
<n-tag size="small" :type="step.status === 'error' ? 'error' : (activeTopology?.runningKeys || [activeTopology?.runningKey]).includes(step.key) ? 'warning' : step.status === 'done' ? 'success' : step.status === 'skipped' ? 'default' : 'info'">
|
||||
{{ step.statusLabel || statusText(step.status) }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<small>{{ step.meta || step.detail }}</small>
|
||||
<n-collapse class="step-detail" arrow-placement="right">
|
||||
<n-collapse-item title="查看事件详情" :name="step.key">
|
||||
<n-p>时间点:{{ step.atMs ?? '-' }}ms · 耗时:{{ formatMs(step.elapsedMs) }}</n-p>
|
||||
<n-p>{{ step.detail }}</n-p>
|
||||
<n-code v-if="step.raw" :code="JSON.stringify(step.raw, null, 2)" language="json" word-wrap />
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="index < requiredTopologyNodes.length - 1" class="topology-connector" :class="pipelineEdges.find(e => e.from === step.key)?.status">
|
||||
<span></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<section class="subagent-lane" v-if="branchTopologyNodes.length || optionalTopologyNodes.length">
|
||||
<n-divider>Subagent / Tool use</n-divider>
|
||||
<n-grid cols="1 s:2" responsive="screen" :x-gap="10" :y-gap="10">
|
||||
<n-gi v-for="step in branchTopologyNodes" :key="step.key">
|
||||
<n-card embedded size="small" class="subagent-node" :class="[step.status, { running: (activeTopology?.runningKeys || [activeTopology?.runningKey]).includes(step.key) }]">
|
||||
<n-space justify="space-between" align="start" :wrap="false">
|
||||
<div><b>{{ step.name }}</b><p>{{ step.agent }}</p></div>
|
||||
<n-tag size="small" :type="(activeTopology?.runningKeys || [activeTopology?.runningKey]).includes(step.key) ? 'warning' : step.status === 'done' ? 'success' : step.status === 'error' ? 'error' : 'info'">
|
||||
{{ step.statusLabel || statusText(step.status) }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<n-text depth="3">{{ step.detail }}</n-text>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi v-for="step in optionalTopologyNodes" v-show="!branchTopologyNodes.length" :key="step.key">
|
||||
<n-card embedded size="small" class="subagent-node optional">
|
||||
<n-space justify="space-between"><b>{{ step.name }}</b><n-tag size="small">可选/等待</n-tag></n-space>
|
||||
<n-text depth="3">{{ step.agent }} · {{ step.detail }}</n-text>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</section>
|
||||
|
||||
<n-alert v-if="pipelineSteps.length" type="info" :show-icon="false" class="topology-detail">
|
||||
<b>当前节点:</b>{{ (pipelineSteps.find(x => activeTopology?.runningKey === x.key) || [...pipelineSteps].reverse().find(x => x.status === 'done') || pipelineSteps[0])?.name }};
|
||||
<b>详情:</b>{{ (pipelineSteps.find(x => activeTopology?.runningKey === x.key) || [...pipelineSteps].reverse().find(x => x.status === 'done') || pipelineSteps[0])?.detail }}
|
||||
</n-alert>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group-page { align-items: stretch; }
|
||||
.panel-card { min-height: 100%; border: 1px solid rgba(28, 63, 51, .08); box-shadow: 0 16px 42px rgba(24, 45, 38, .07); }
|
||||
.chat-card {
|
||||
height: calc(100vh - 128px);
|
||||
min-height: 640px;
|
||||
}
|
||||
.chat-card-body {
|
||||
height: calc(100vh - 260px);
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-heading { margin: 0; font-size: 20px; }
|
||||
.group-title-block { min-width: 0; }
|
||||
.group-switcher { width: 190px; }
|
||||
.route-tag { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.messages {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
padding: 14px 12px 14px 4px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
border-radius: 16px;
|
||||
background: #f7f9f6;
|
||||
border: 1px solid rgba(28, 63, 51, .09);
|
||||
}
|
||||
.message-stack { padding-right: 4px; }
|
||||
.messages::-webkit-scrollbar { width: 10px; }
|
||||
.messages::-webkit-scrollbar-track { background: #f3f6fa; border-radius: 999px; }
|
||||
.messages::-webkit-scrollbar-thumb { background: linear-gradient(180deg, #b8c7d9, #98a8ba); border: 2px solid #f3f6fa; border-radius: 999px; }
|
||||
.messages::-webkit-scrollbar-thumb:hover { background: linear-gradient(180deg, #8fa1b5, #718399); }
|
||||
.message-row { display: flex; gap: 12px; cursor: pointer; transition: transform .15s ease, opacity .15s ease; }
|
||||
.message-row:hover { transform: translateY(-1px); }
|
||||
.message-row.dimmed { opacity: .45; }
|
||||
.message-row.selected .message-bubble { outline: 2px solid rgba(39, 138, 100, .28); }
|
||||
.message-bubble { max-width: min(760px, calc(100% - 52px)); background: #fff; border-left: 3px solid #d8e0dc; box-shadow: 0 8px 24px rgba(28, 48, 41, .05); }
|
||||
.message-bubble.assistant { background: #eef8f3; border-left-color: #2b8a68; }
|
||||
.message-meta { font-size: 12px; }
|
||||
.message-text, .segments :deep(.n-p) { white-space: pre-wrap; margin: 0; }
|
||||
.attachment-link { display: grid; gap: 4px; color: inherit; text-decoration: none; }
|
||||
.stats-grid { margin-bottom: 16px; }
|
||||
.metric-tile { min-height: 72px; padding: 10px 12px; border: 1px solid rgba(30, 67, 54, .08); border-radius: 12px; background: #f6f8f5; }
|
||||
.metric-tile span { display: block; margin-bottom: 8px; color: #78857f; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.metric-tile strong { color: #19372e; font: 700 16px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.observability-card { height: calc(100vh - 128px); overflow: auto; }
|
||||
.composer-shell { margin-top: 12px; padding: 12px; border: 1px solid rgba(28, 63, 51, .1); border-radius: 15px; background: #fff; box-shadow: 0 -8px 30px rgba(27, 53, 44, .06); }
|
||||
.composer-shell :deep(.n-input) { --n-border: 0 !important; --n-border-hover: 0 !important; --n-border-focus: 0 !important; --n-box-shadow-focus: none !important; }
|
||||
.composer-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding-top: 8px; border-top: 1px solid #edf0ed; }
|
||||
.empty-copy { max-width: 380px; color: #7a8781; line-height: 1.6; }
|
||||
.flow-tags { margin: 8px 0 18px; }
|
||||
.thread-id { display: block; margin-top: 6px; word-break: break-all; }
|
||||
.topology-card { overflow: hidden; }
|
||||
.topology-updated { font-size: 12px; }
|
||||
.topology-board { display: grid; gap: 10px; }
|
||||
.topology-step { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 10px; align-items: start; }
|
||||
.step-orb { position: relative; width: 34px; height: 34px; border-radius: 999px; display: grid; place-items: center; background: #e8edf5; color: #64748b; font-weight: 700; }
|
||||
.topology-step.done .step-orb { background: #e8f7ef; color: #18a058; }
|
||||
.topology-step.running .step-orb { background: #fff4df; color: #f0a020; }
|
||||
.topology-step.error .step-orb { background: #fee2e2; color: #d03050; }
|
||||
.live-ring { position: absolute; inset: -5px; border: 2px solid #f0a020; border-radius: inherit; animation: topology-pulse 1.1s infinite; }
|
||||
.step-body { border: 1px solid #edf0f5; border-radius: 14px; padding: 12px; background: rgba(255, 255, 255, .78); transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; }
|
||||
.topology-step.running .step-body { border-color: #f0a02066; box-shadow: 0 0 0 4px #f0a02018; transform: translateX(2px); }
|
||||
.step-body p, .subagent-node p { margin: 2px 0 0; color: #7a828e; font-size: 12px; }
|
||||
.step-body small { display: block; margin-top: 8px; color: #667085; }
|
||||
.step-detail { margin-top: 6px; }
|
||||
.topology-connector { display: grid; grid-template-columns: 38px minmax(0, 1fr); height: 14px; }
|
||||
.topology-connector span { grid-column: 1; justify-self: center; width: 2px; height: 100%; background: #d8dee9; }
|
||||
.topology-connector.active span { background: #18a058; }
|
||||
.subagent-lane { margin-top: 12px; }
|
||||
.subagent-node { border: 1px solid transparent; }
|
||||
.subagent-node.running { border-color: #f0a02066; box-shadow: 0 0 0 4px #f0a02018; }
|
||||
.subagent-node.done { border-color: #18a05833; }
|
||||
.subagent-node.optional { opacity: .72; }
|
||||
.topology-detail { margin-top: 14px; }
|
||||
@keyframes topology-pulse { from { opacity: .85; transform: scale(.9); } to { opacity: 0; transform: scale(1.35); } }
|
||||
@media (max-width: 900px) {
|
||||
.chat-card { height: auto; min-height: 620px; }
|
||||
.chat-card-body { height: 620px; min-height: 500px; }
|
||||
.messages { min-height: 0; }
|
||||
.observability-card { height: auto; max-height: none; }
|
||||
.header-actions .route-tag { display: none; }
|
||||
.composer-actions { align-items: flex-end; flex-direction: column; }
|
||||
.group-switcher { width: 150px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script>
|
||||
import { inject } from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'MediaPage',
|
||||
setup() {
|
||||
return inject('appCtx');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-grid cols="1 l:12" responsive="screen" :x-gap="18" :y-gap="18">
|
||||
<n-gi span="1 l:8">
|
||||
<n-card :bordered="false" class="panel-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<n-h2 class="card-heading">媒体 / 图片 URL</n-h2>
|
||||
<n-text depth="3">搜索真实网页中的图片 URL,或下载用户提供的图片 URL 到本地缓存。</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra><n-button secondary @click="loadMediaSources">刷新来源</n-button></template>
|
||||
|
||||
<n-form label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-form-item-gi span="1 m:2" label="图片搜索 Query">
|
||||
<n-input v-model:value="mediaForm.query" placeholder="例如:Apple MacBook official image" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="搜索网页数">
|
||||
<n-input-number v-model:value="mediaForm.maxPages" :min="1" :max="10" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="最多图片数">
|
||||
<n-input-number v-model:value="mediaForm.maxResults" :min="1" :max="30" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi span="1 m:2">
|
||||
<n-button type="primary" :loading="mediaLoading" :disabled="!mediaForm.query.trim()" @click="searchMedia">{{ mediaLoading ? '处理中...' : '搜索图片 URL' }}</n-button>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi span="1 m:2" label="直接图片 URL">
|
||||
<n-input v-model:value="mediaForm.imageUrl" placeholder="https://example.com/image.jpg" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi span="1 m:2" label="来源页面 URL,可选">
|
||||
<n-input v-model:value="mediaForm.pageUrl" placeholder="https://example.com/page" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi span="1 m:2">
|
||||
<n-button secondary :loading="mediaLoading" :disabled="!mediaForm.imageUrl.trim()" @click="downloadMedia(null)">下载并缓存直接 URL</n-button>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
|
||||
<n-alert v-if="mediaError" type="error" class="section-gap">{{ mediaError }}</n-alert>
|
||||
|
||||
<n-card v-if="mediaResult" embedded class="section-gap">
|
||||
<template #header>搜索结果:{{ mediaResult.query }}</template>
|
||||
<template #header-extra><n-tag>{{ mediaResult.source }}</n-tag></template>
|
||||
<n-list>
|
||||
<n-list-item v-for="(item, mi) in mediaResult.items" :key="mi">
|
||||
<n-thing :title="item.title || '未命名图片'" :description="item.source">
|
||||
<template #avatar>
|
||||
<a :href="item.imageUrl" target="_blank"><n-image :src="item.cached ? mediaUrl(item.cached.url) : item.imageUrl" width="120" height="90" object-fit="cover" preview-disabled /></a>
|
||||
</template>
|
||||
<n-space vertical size="small">
|
||||
<n-text class="url-line">图片:<a :href="item.imageUrl" target="_blank">{{ item.imageUrl }}</a></n-text>
|
||||
<n-text v-if="item.pageUrl" class="url-line">页面:<a :href="item.pageUrl" target="_blank">{{ item.pageUrl }}</a></n-text>
|
||||
<n-space>
|
||||
<n-button size="small" secondary :loading="mediaLoading" @click="downloadMedia(item)">下载缓存</n-button>
|
||||
<n-button v-if="item.cached" size="small" tag="a" :href="mediaUrl(item.cached.url)" target="_blank">打开缓存</n-button>
|
||||
</n-space>
|
||||
<n-code v-if="item.cached" :code="JSON.stringify(item.cached, null, 2)" language="json" word-wrap />
|
||||
</n-space>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi span="1 l:4">
|
||||
<n-card :bordered="false" class="panel-card">
|
||||
<template #header>可用图片来源</template>
|
||||
<n-list hoverable>
|
||||
<n-list-item v-for="s in mediaSources" :key="s.id">
|
||||
<n-thing :title="s.name" :description="`${s.kind} · ${s.needApiKey}`">{{ s.notes }}</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel-card { box-shadow: 0 10px 30px rgba(31, 34, 37, .06); }
|
||||
.card-heading { margin: 0; font-size: 20px; }
|
||||
.section-gap { margin-top: 16px; }
|
||||
.url-line { word-break: break-all; }
|
||||
a { color: #2080f0; text-decoration: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script>
|
||||
import { inject } from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'TasksPage',
|
||||
setup() {
|
||||
return inject('appCtx');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card :bordered="false" class="panel-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<n-h2 class="card-heading">任务</n-h2>
|
||||
<n-text depth="3">查看 Agent 任务状态。</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra><n-button secondary @click="loadTasks">刷新</n-button></template>
|
||||
|
||||
<n-empty v-if="!tasks.length" description="暂无任务" />
|
||||
<div v-else class="task-grid">
|
||||
<article v-for="t in tasks" :key="t.taskId" class="task-card" :class="String(t.status || '').toLowerCase()">
|
||||
<header>
|
||||
<div>
|
||||
<n-tag size="small" round :type="t.status === 'completed' ? 'success' : t.status === 'failed' ? 'error' : t.status === 'cancelled' ? 'warning' : 'info'">{{ statusText(t.status) }}</n-tag>
|
||||
<span class="task-time">{{ new Date(t.createdAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<span class="task-generation">GEN {{ t.generation || 1 }}</span>
|
||||
</header>
|
||||
<p class="task-text">{{ t.mergedText || '无文本内容' }}</p>
|
||||
<dl>
|
||||
<div><dt>群 / 用户</dt><dd>{{ t.groupId }} / {{ t.userId }}</dd></div>
|
||||
<div><dt>线程</dt><dd>{{ t.threadId }}</dd></div>
|
||||
<div><dt>任务 ID</dt><dd>{{ t.taskId }}</dd></div>
|
||||
</dl>
|
||||
<n-alert v-if="t.cancellationReason" type="warning" :show-icon="false">{{ t.cancellationReason }}</n-alert>
|
||||
<n-alert v-if="t.error" type="error" :show-icon="false">{{ t.error }}</n-alert>
|
||||
</article>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel-card { border: 1px solid rgba(28, 63, 51, .08); box-shadow: 0 16px 42px rgba(24, 45, 38, .07); }
|
||||
.card-heading { margin: 0; font-size: 20px; }
|
||||
.task-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
|
||||
.task-card { position: relative; overflow: hidden; padding: 16px; border: 1px solid #e3e8e4; border-radius: 14px; background: #fff; }
|
||||
.task-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: #7d938a; }
|
||||
.task-card.running::before { background: #d69a36; }
|
||||
.task-card.completed::before { background: #2b936c; }
|
||||
.task-card.failed::before { background: #d45d62; }
|
||||
.task-card.cancelled::before { background: #a78350; }
|
||||
.task-card header { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
|
||||
.task-time { margin-left: 8px; color: #87928d; font-size: 12px; }
|
||||
.task-generation { color: #83918b; font: 600 11px/1 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.task-text { min-height: 44px; margin: 16px 0; color: #25342f; line-height: 1.55; }
|
||||
.task-card dl { display: grid; gap: 7px; margin: 0 0 12px; }
|
||||
.task-card dl div { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; }
|
||||
.task-card dt { color: #85918c; font-size: 12px; }
|
||||
.task-card dd { margin: 0; overflow: hidden; color: #50615a; font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@media (max-width: 640px) { .task-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import naive from 'naive-ui';
|
||||
import App from './App.vue';
|
||||
|
||||
createApp(App).use(naive).mount('#app');
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/naive-ui')) return 'naive-ui'
|
||||
if (id.includes('node_modules/vue')) return 'vue-runtime'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_BASE_URL || 'http://127.0.0.1:5000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user