feat: extract MyAgent core and web console

This commit is contained in:
justme committed 2026-09-29 20:47:00 +08:00
commit e491b1a35b
83 files changed
+13651

No files matched your search

+15
View File
@@ -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>
+1621
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -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
+45
View File
@@ -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
};
}
+64
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import naive from 'naive-ui';
import App from './App.vue';
createApp(App).use(naive).mount('#app');
+25
View File
@@ -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
}
}
}
})