feat: Add openclaw overview button (#12319)

This commit is contained in:
CityFun
2026-03-24 10:42:05 +00:00
committed by GitHub
parent e52cfeeb78
commit 39704ef4e5
19 changed files with 475 additions and 10 deletions
+21
View File
@@ -111,6 +111,27 @@ func (b *BaseApi) UpdateAgentModelConfig(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Get Agent overview
// @Accept json
// @Param request body dto.AgentOverviewReq true "request"
// @Success 200 {object} dto.AgentOverview
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/overview [post]
func (b *BaseApi) GetAgentOverview(c *gin.Context) {
var req dto.AgentOverviewReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := agentService.GetOverview(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags AI
// @Summary Get Providers
// @Success 200 {array} dto.ProviderInfo
+18
View File
@@ -69,6 +69,24 @@ type AgentModelConfigUpdateReq struct {
Model string `json:"model" validate:"required"`
}
type AgentOverviewReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentOverview struct {
Snapshot AgentOverviewSnapshot `json:"snapshot"`
}
type AgentOverviewSnapshot struct {
ContainerStatus string `json:"containerStatus"`
AppVersion string `json:"appVersion"`
DefaultModel string `json:"defaultModel"`
ChannelCount int `json:"channelCount"`
SkillCount int `json:"skillCount"`
JobCount int `json:"jobCount"`
SessionCount int `json:"sessionCount"`
}
type AgentAccountModel struct {
RecordID uint `json:"recordId"`
ID string `json:"id"`
+1
View File
@@ -29,6 +29,7 @@ type IAgentService interface {
Delete(req dto.AgentDeleteReq) error
ResetToken(req dto.AgentTokenResetReq) error
UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
GetOverview(req dto.AgentOverviewReq) (*dto.AgentOverview, error)
GetProviders() ([]dto.ProviderInfo, error)
GetSecurityConfig(req dto.AgentSecurityConfigReq) (*dto.AgentSecurityConfig, error)
UpdateSecurityConfig(req dto.AgentSecurityConfigUpdateReq) error
+182
View File
@@ -0,0 +1,182 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
const (
openclawCronJobsPath = "/home/node/.openclaw/cron/jobs.json"
)
func (a AgentService) GetOverview(req dto.AgentOverviewReq) (*dto.AgentOverview, error) {
agent, install, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
if agent.AgentType != constant.AppOpenclaw {
return nil, fmt.Errorf("copaw does not support overview")
}
overview := &dto.AgentOverview{
Snapshot: dto.AgentOverviewSnapshot{
ContainerStatus: install.Status,
AppVersion: install.Version,
DefaultModel: extractOpenclawDefaultModel(conf),
ChannelCount: countOpenclawConfiguredChannels(conf),
},
}
if overview.Snapshot.DefaultModel == "" {
overview.Snapshot.DefaultModel = agent.Model
}
if install.Status != constant.StatusRunning {
return overview, nil
}
skillCount, err := loadOpenclawOverviewSkillStats(install.ContainerName)
if err == nil {
overview.Snapshot.SkillCount = skillCount
}
sessionCount, err := loadOpenclawOverviewSessionCount(install.ContainerName)
if err == nil {
overview.Snapshot.SessionCount = sessionCount
}
jobCount, err := loadOpenclawOverviewJobCount(install.ContainerName)
if err == nil {
overview.Snapshot.JobCount = jobCount
}
return overview, nil
}
func extractOpenclawDefaultModel(conf map[string]interface{}) string {
agents, ok := conf["agents"].(map[string]interface{})
if !ok {
return ""
}
defaults, ok := agents["defaults"].(map[string]interface{})
if !ok {
return ""
}
model, ok := defaults["model"].(map[string]interface{})
if !ok {
return ""
}
primary, _ := model["primary"].(string)
return strings.TrimSpace(primary)
}
func countOpenclawConfiguredChannels(conf map[string]interface{}) int {
channels, ok := conf["channels"].(map[string]interface{})
if !ok {
return 0
}
count := 0
for _, value := range channels {
channel, ok := value.(map[string]interface{})
if !ok || len(channel) == 0 {
continue
}
count++
}
return count
}
func loadOpenclawOverviewSkillStats(containerName string) (int, error) {
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw skills list --json 2>&1",
30*time.Second,
containerName,
)
if err != nil {
return 0, err
}
if strings.TrimSpace(output) == "" {
return 0, nil
}
skills, err := parseOpenclawSkillsList(output)
if err != nil {
return 0, err
}
return len(skills), nil
}
func loadOpenclawOverviewSessionCount(containerName string) (int, error) {
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw sessions --all-agents --json",
20*time.Second,
containerName,
)
if err != nil {
return 0, err
}
return parseOpenclawSessionCount(output)
}
func loadOpenclawOverviewJobCount(containerName string) (int, error) {
script := fmt.Sprintf(`if [ -f %q ]; then cat %q; fi`, openclawCronJobsPath, openclawCronJobsPath)
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s sh -c %q",
20*time.Second,
containerName,
script,
)
if err != nil {
return 0, err
}
return parseOpenclawCronCount(output)
}
func parseOpenclawSessionCount(output string) (int, error) {
if strings.TrimSpace(output) == "" {
return 0, nil
}
var payload interface{}
if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil {
return 0, err
}
switch value := payload.(type) {
case []interface{}:
return len(value), nil
case map[string]interface{}:
if count, ok := value["count"].(float64); ok {
return int(count), nil
}
if sessions, ok := value["sessions"].([]interface{}); ok {
return len(sessions), nil
}
}
return 0, nil
}
func parseOpenclawCronCount(output string) (int, error) {
if strings.TrimSpace(output) == "" {
return 0, nil
}
var payload interface{}
if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil {
return 0, err
}
switch value := payload.(type) {
case []interface{}:
return len(value), nil
case map[string]interface{}:
if total, ok := value["total"].(float64); ok {
return int(total), nil
}
if jobs, ok := value["jobs"].([]interface{}); ok {
return len(jobs), nil
}
return len(value), nil
default:
return 0, nil
}
}
+1
View File
@@ -45,6 +45,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/delete", baseApi.DeleteAgent)
aiToolsRouter.POST("/agents/token/reset", baseApi.ResetAgentToken)
aiToolsRouter.POST("/agents/model/update", baseApi.UpdateAgentModelConfig)
aiToolsRouter.POST("/agents/overview", baseApi.GetAgentOverview)
aiToolsRouter.GET("/agents/providers", baseApi.GetAgentProviders)
aiToolsRouter.POST("/agents/accounts", baseApi.CreateAgentAccount)
aiToolsRouter.POST("/agents/accounts/update", baseApi.UpdateAgentAccount)
+18
View File
@@ -303,6 +303,24 @@ export namespace AI {
model: string;
}
export interface AgentOverviewReq {
agentId: number;
}
export interface AgentOverviewSnapshot {
containerStatus: string;
appVersion: string;
defaultModel: string;
channelCount: number;
skillCount: number;
jobCount: number;
sessionCount: number;
}
export interface AgentOverview {
snapshot: AgentOverviewSnapshot;
}
export interface AgentAccountModel {
recordId: number;
id: string;
+4
View File
@@ -113,6 +113,10 @@ export const updateAgentModelConfig = (req: AI.AgentModelConfigUpdateReq) => {
return http.post(`/ai/agents/model/update`, req);
};
export const getAgentOverview = (req: AI.AgentOverviewReq) => {
return http.post<AI.AgentOverview>(`/ai/agents/overview`, req);
};
export const getAgentProviders = () => {
return http.get<AI.ProviderInfo[]>(`/ai/agents/providers`);
};
+6
View File
@@ -717,6 +717,12 @@ const message = {
channelsTab: 'Channels',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
defaultModel: 'Default Model',
channelCount: 'Configured Channels',
skillCount: 'Skills',
jobCount: 'Scheduled Jobs',
sessionCount: 'Sessions',
weixin: 'Weixin',
wecom: 'WeCom',
dingtalk: 'DingTalk',
+6
View File
@@ -725,6 +725,12 @@ const message = {
channelsTab: 'Channels',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
defaultModel: 'Default Model',
channelCount: 'Configured Channels',
skillCount: 'Skills',
jobCount: 'Scheduled Jobs',
sessionCount: 'Sessions',
weixin: 'Weixin',
wecom: 'WeCom',
dingtalk: 'DingTalk',
+6
View File
@@ -718,6 +718,12 @@ const message = {
channelsTab: 'Channels',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
defaultModel: 'Default Model',
channelCount: 'Configured Channels',
skillCount: 'Skills',
jobCount: 'Scheduled Jobs',
sessionCount: 'Sessions',
weixin: 'Weixin',
wecom: 'WeCom',
dingtalk: 'DingTalk',
+6
View File
@@ -710,6 +710,12 @@ const message = {
channelsTab: 'Channels',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
defaultModel: 'Default Model',
channelCount: 'Configured Channels',
skillCount: 'Skills',
jobCount: 'Scheduled Jobs',
sessionCount: 'Sessions',
weixin: 'Weixin',
wecom: 'WeCom',
dingtalk: 'DingTalk',
+6
View File
@@ -725,6 +725,12 @@ const message = {
channelsTab: 'Channels',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
defaultModel: 'Default Model',
channelCount: 'Configured Channels',
skillCount: 'Skills',
jobCount: 'Scheduled Jobs',
sessionCount: 'Sessions',
weixin: 'Weixin',
wecom: 'WeCom',
dingtalk: 'DingTalk',
+6
View File
@@ -720,6 +720,12 @@ const message = {
channelsTab: 'Channels',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
defaultModel: 'Default Model',
channelCount: 'Configured Channels',
skillCount: 'Skills',
jobCount: 'Scheduled Jobs',
sessionCount: 'Sessions',
weixin: 'Weixin',
wecom: 'WeCom',
dingtalk: 'DingTalk',
+6
View File
@@ -717,6 +717,12 @@ const message = {
channelsTab: 'Channels',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
defaultModel: 'Default Model',
channelCount: 'Configured Channels',
skillCount: 'Skills',
jobCount: 'Scheduled Jobs',
sessionCount: 'Sessions',
weixin: 'Weixin',
wecom: 'WeCom',
dingtalk: 'DingTalk',
+6
View File
@@ -721,6 +721,12 @@ const message = {
channelsTab: 'Channels',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
defaultModel: 'Default Model',
channelCount: 'Configured Channels',
skillCount: 'Skills',
jobCount: 'Scheduled Jobs',
sessionCount: 'Sessions',
weixin: 'Weixin',
wecom: 'WeCom',
dingtalk: 'DingTalk',
+7 -1
View File
@@ -650,7 +650,7 @@ const message = {
webuiPort: 'WebUI ',
allowedOrigins: '訪問地址',
allowedOriginsHelper:
'一行一個完整訪問地址建議優先使用 HTTPS例如 https://192.168.1.2:18789未設定預設訪問地址時請手動填寫',
'一行一個完整訪問地址建議優先使用 HTTPS例如 https://192.168.1.2:18789.未設定預設訪問地址時請手動填寫',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: '請至少填寫一個訪問地址',
allowedOriginsInvalid: '訪問地址格式錯誤請輸入 http(s)://網域或IP[:埠]',
@@ -682,6 +682,12 @@ const message = {
switchModelSuccess: '模型切換成功',
channelsTab: '頻道',
configFileRestartHelper: '保存配置檔後需要立即重新啟動容器才能生效',
overviewSnapshot: '狀態概覽',
defaultModel: '預設模型',
channelCount: '已配置頻道數量',
skillCount: '技能數量',
jobCount: '定時任務數量',
sessionCount: '會話數量',
weixin: '微信',
wecom: '企業微信',
dingtalk: '釘釘',
+7 -1
View File
@@ -649,7 +649,7 @@ const message = {
webuiPort: 'WebUI 端口',
allowedOrigins: '访问地址',
allowedOriginsHelper:
'一行一个完整访问地址建议优先使用 HTTPS例如 https://192.168.1.2:18789未配置默认访问地址时请手动填写',
'一行一个完整访问地址建议优先使用 HTTPS例如 https://192.168.1.2:18789.未配置默认访问地址时请手动填写',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: '请至少填写一个访问地址',
allowedOriginsInvalid: '访问地址格式错误请输入 http(s)://域名或IP[:端口]',
@@ -681,6 +681,12 @@ const message = {
switchModelSuccess: '模型切换成功',
channelsTab: '频道',
configFileRestartHelper: '保存配置文件后需要重启容器才能生效',
overviewSnapshot: '状态概览',
defaultModel: '默认模型',
channelCount: '已配置频道数量',
skillCount: '技能数量',
jobCount: '定时任务数量',
sessionCount: '会话数量',
weixin: '微信',
wecom: '企业微信',
dingtalk: '钉钉',
@@ -0,0 +1,151 @@
<template>
<DrawerPro v-model="drawerVisible" :header="$t('menu.home')" :resource="resource" size="60%" @close="handleClose">
<div v-loading="loading" class="overview-drawer">
<div class="toolbar">
<el-button :loading="loading" @click="loadOverview">
<el-icon><Refresh /></el-icon>
{{ $t('commons.button.refresh') }}
</el-button>
</div>
<section class="section">
<span class="section-title">{{ $t('aiTools.agents.overviewSnapshot') }}</span>
<el-divider class="divider" />
<el-row :gutter="20" class="metrics-row">
<el-col v-for="item in snapshotCards" :key="item.label" :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
<el-form-item label-position="top">
<template #label>
<span class="metric-label">{{ item.label }}</span>
</template>
<span class="metric-value">{{ item.value }}</span>
</el-form-item>
</el-col>
</el-row>
</section>
</div>
</DrawerPro>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Refresh } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n';
import { AI } from '@/api/interface/ai';
import { getAgentOverview } from '@/api/modules/ai';
const { t } = useI18n();
const drawerVisible = ref(false);
const loading = ref(false);
const resource = ref('');
const agentId = ref(0);
const data = ref<AI.AgentOverview>({
snapshot: {
containerStatus: '',
appVersion: '',
defaultModel: '',
channelCount: 0,
skillCount: 0,
jobCount: 0,
sessionCount: 0,
},
});
const formatNumber = (value: number) => {
return new Intl.NumberFormat().format(value || 0);
};
const formatText = (value: string) => {
return value || '-';
};
const snapshotCards = computed(() => [
{ label: t('commons.table.status'), value: formatText(data.value.snapshot.containerStatus) },
{ label: t('aiTools.agents.appVersion'), value: formatText(data.value.snapshot.appVersion) },
{ label: t('aiTools.agents.defaultModel'), value: formatText(data.value.snapshot.defaultModel) },
{ label: t('aiTools.agents.channelCount'), value: formatNumber(data.value.snapshot.channelCount) },
{ label: t('aiTools.agents.skillCount'), value: formatNumber(data.value.snapshot.skillCount) },
{ label: t('aiTools.agents.jobCount'), value: formatNumber(data.value.snapshot.jobCount) },
{ label: t('aiTools.agents.sessionCount'), value: formatNumber(data.value.snapshot.sessionCount) },
]);
const loadOverview = async () => {
if (!agentId.value) {
return;
}
loading.value = true;
try {
const res = await getAgentOverview({ agentId: agentId.value });
data.value = res.data;
} finally {
loading.value = false;
}
};
const acceptParams = async (row: AI.AgentItem) => {
agentId.value = row.id;
resource.value = row.name;
drawerVisible.value = true;
await loadOverview();
};
const handleClose = () => {
drawerVisible.value = false;
};
defineExpose({
acceptParams,
});
</script>
<style scoped lang="scss">
.overview-drawer {
display: flex;
flex-direction: column;
gap: 20px;
}
.toolbar {
display: flex;
justify-content: flex-end;
}
.section {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 20px;
font-weight: 600;
}
.divider {
display: block;
height: 1px;
width: 100%;
margin: 12px 0;
border-top: 1px var(--el-border-color) var(--el-border-style);
}
.metrics-row {
padding: 0 16px;
}
.metric-label {
color: var(--el-text-color-secondary);
}
.metric-value {
display: inline-block;
margin-top: 2px;
font-size: 16px;
font-weight: 600;
word-break: break-word;
}
@media (max-width: 1200px) {
.metrics-row {
padding: 0;
}
}
</style>
+17 -8
View File
@@ -58,7 +58,7 @@
:label="$t('aiTools.model.model')"
show-overflow-tooltip
prop="provider"
min-width="120"
min-width="150"
>
<template #default="{ row }">
<template v-if="row.agentType !== 'copaw'">
@@ -70,7 +70,7 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.port')" prop="webUIPort" min-width="150">
<el-table-column :label="$t('commons.table.port')" prop="webUIPort" min-width="180">
<template #default="{ row }">
<el-button icon="Position" plain size="small" @click="jumpWebUI(row)">
{{ $t('aiTools.agents.webuiPort') }}: {{ row.webUIPort }}
@@ -86,7 +86,7 @@
</el-button>
</template>
</el-table-column>
<el-table-column label="Token" min-width="80">
<el-table-column label="Token" min-width="120">
<template #default="{ row }">
<el-space v-if="row.agentType !== 'copaw'">
<CopyButton :content="row.token" />
@@ -106,10 +106,10 @@
/>
<fu-table-operations
:buttons="buttons"
min-width="220"
min-width="200"
:label="$t('commons.table.operate')"
fixed="right"
:ellipsis="3"
:ellipsis="4"
/>
</ComplexTable>
</template>
@@ -118,6 +118,7 @@
<TaskLog ref="taskLogRef" @close="search" />
<DeleteDialog ref="deleteRef" @close="search" />
<ConfigDrawer ref="configRef" @updated="search" />
<OverviewDrawer ref="overviewRef" />
<AppUpgrade ref="upgradeRef" @close="search" />
<ComposeLogs ref="composeLogRef" />
<AgentTerminalDialog ref="dialogTerminalRef" />
@@ -140,6 +141,7 @@ import RouterMenu from '@/views/ai/agents/index.vue';
import AddDialog from '@/views/ai/agents/agent/add/index.vue';
import DeleteDialog from '@/views/ai/agents/agent/delete/index.vue';
import ConfigDrawer from '@/views/ai/agents/agent/config/index.vue';
import OverviewDrawer from '@/views/ai/agents/agent/components/overview.vue';
import AppUpgrade from '@/views/app-store/installed/upgrade/index.vue';
import TaskLog from '@/components/log/task/index.vue';
import ComposeLogs from '@/components/log/compose/index.vue';
@@ -160,6 +162,7 @@ const addRef = ref();
const taskLogRef = ref();
const deleteRef = ref();
const configRef = ref();
const overviewRef = ref();
const upgradeRef = ref();
const composeLogRef = ref();
const dialogTerminalRef = ref();
@@ -172,6 +175,11 @@ const noApp = ref(false);
const searchName = ref('');
const buttons = [
{
label: i18n.global.t('menu.home'),
click: (row: AI.AgentItem) => openOverview(row),
show: (row: AI.AgentItem) => row.agentType !== 'copaw',
},
{
label: i18n.global.t('menu.config'),
click: (row: AI.AgentItem) => openConfig(row),
@@ -366,12 +374,13 @@ const onResetToken = async (row: AI.AgentItem) => {
};
const openConfig = (row: AI.AgentItem) => {
if (row.agentType === 'copaw') {
return;
}
configRef.value?.open(row);
};
const openOverview = (row: AI.AgentItem) => {
overviewRef.value?.acceptParams(row);
};
const openUpgrade = async (row: AI.AgentItem) => {
const res = await searchAppInstalled({ page: 1, pageSize: 200, name: row.name, update: true });
const appInstall = (res.data.items || []).find((item: App.AppInstallDto) => item.id === row.appInstallId);