feat: i18n
This commit is contained in:
@@ -187,7 +187,7 @@ class FileDownloadTool(FunctionTool):
|
||||
os.remove(local_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing temp file {local_path}: {e}")
|
||||
|
||||
|
||||
return f"File downloaded successfully to {local_path} and sent to user. The file has been removed from local storage."
|
||||
|
||||
return f"File downloaded successfully to {local_path}"
|
||||
|
||||
@@ -24,7 +24,6 @@ from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryMana
|
||||
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType
|
||||
from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager
|
||||
from astrbot.core.provider.manager import ProviderManager
|
||||
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
|
||||
from astrbot.core.provider.provider import (
|
||||
EmbeddingProvider,
|
||||
Provider,
|
||||
@@ -36,6 +35,7 @@ from astrbot.core.star.filter.platform_adapter_type import (
|
||||
ADAPTER_NAME_2_TYPE,
|
||||
PlatformAdapterType,
|
||||
)
|
||||
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
|
||||
|
||||
from ..exceptions import ProviderNotFoundError
|
||||
from .filter.command import CommandFilter
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -76,9 +76,7 @@ class CronRoute(Route):
|
||||
run_at = payload.get("run_at")
|
||||
|
||||
if not session:
|
||||
return jsonify(
|
||||
Response().error("session is required").__dict__
|
||||
)
|
||||
return jsonify(Response().error("session is required").__dict__)
|
||||
if run_once and not run_at:
|
||||
return jsonify(
|
||||
Response().error("run_at is required when run_once=true").__dict__
|
||||
|
||||
@@ -52,6 +52,8 @@ export class I18nLoader {
|
||||
{ name: 'features/auth', path: 'features/auth.json' },
|
||||
{ name: 'features/chart', path: 'features/chart.json' },
|
||||
{ name: 'features/dashboard', path: 'features/dashboard.json' },
|
||||
{ name: 'features/cron', path: 'features/cron.json' },
|
||||
{ name: 'features/subagent', path: 'features/subagent.json' },
|
||||
{ name: 'features/alkaid/index', path: 'features/alkaid/index.json' },
|
||||
{ name: 'features/alkaid/knowledge-base', path: 'features/alkaid/knowledge-base.json' },
|
||||
{ name: 'features/alkaid/memory', path: 'features/alkaid/memory.json' },
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Future Task Management",
|
||||
"beta": "Beta",
|
||||
"subtitle": "See scheduled tasks for AstrBot. AstrBot will wake up, run them, and deliver the results.",
|
||||
"proactive": {
|
||||
"supported": "Proactive delivery is available on: {platforms}",
|
||||
"unsupported": "No proactive messaging platforms enabled. Turn them on in Platform settings."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"create": "New Task",
|
||||
"refresh": "Refresh",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Create"
|
||||
},
|
||||
"table": {
|
||||
"title": "Registered Tasks",
|
||||
"empty": "No tasks yet.",
|
||||
"headers": {
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"cron": "Cron",
|
||||
"nextRun": "Next Run",
|
||||
"lastRun": "Last Run",
|
||||
"note": "Note",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"type": {
|
||||
"once": "One-off",
|
||||
"recurring": "Recurring",
|
||||
"activeAgent": "Active Agent",
|
||||
"workflow": "Workflow",
|
||||
"unknown": "{type}"
|
||||
},
|
||||
"timezoneLocal": "local",
|
||||
"notAvailable": "—"
|
||||
},
|
||||
"form": {
|
||||
"title": "New Task",
|
||||
"runOnce": "One-off task",
|
||||
"name": "Task name",
|
||||
"note": "Task description",
|
||||
"cron": "Cron expression",
|
||||
"cronPlaceholder": "0 9 * * *",
|
||||
"runAt": "Run at",
|
||||
"session": "Target session (platform_id:message_type:session_id)",
|
||||
"timezone": "Timezone (optional, e.g. Asia/Shanghai)",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Failed to load tasks",
|
||||
"updateFailed": "Failed to update",
|
||||
"deleteSuccess": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"sessionRequired": "Session is required",
|
||||
"noteRequired": "Description is required",
|
||||
"cronRequired": "Cron expression is required",
|
||||
"runAtRequired": "Please select run time",
|
||||
"createSuccess": "Created successfully",
|
||||
"createFailed": "Failed to create"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "SubAgent Orchestration",
|
||||
"beta": "Beta",
|
||||
"subtitle": "The main LLM only chats and delegates; tools live on individual SubAgents."
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"save": "Save",
|
||||
"add": "Add SubAgent",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"switches": {
|
||||
"enable": "Enable SubAgent orchestration",
|
||||
"dedupe": "Deduplicate main LLM tools (hide tools duplicated by SubAgents)"
|
||||
},
|
||||
"description": {
|
||||
"disabled": "When off: SubAgent is disabled; the main LLM mounts tools via persona rules (all by default) and calls them directly.",
|
||||
"enabled": "When on: the main LLM keeps its own tools and mounts transfer_to_* delegate tools. With deduplication, tools overlapping with SubAgents are removed from the main tool set."
|
||||
},
|
||||
"section": {
|
||||
"title": "SubAgents"
|
||||
},
|
||||
"cards": {
|
||||
"statusEnabled": "Enabled",
|
||||
"statusDisabled": "Disabled",
|
||||
"unnamed": "Untitled SubAgent",
|
||||
"transferPrefix": "transfer_to_{name}",
|
||||
"switchLabel": "Enable",
|
||||
"previewTitle": "Preview: handoff tool shown to the main LLM",
|
||||
"personaChip": "Persona: {id}"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Agent name (used for transfer_to_{name})",
|
||||
"nameHint": "Use lowercase letters + underscores; must be globally unique.",
|
||||
"providerLabel": "Chat Provider (optional)",
|
||||
"providerHint": "Leave empty to follow the global default provider.",
|
||||
"personaLabel": "Choose Persona",
|
||||
"personaHint": "The SubAgent inherits the selected Persona's system settings and tools.",
|
||||
"descriptionLabel": "Description for the main LLM (used to decide handoff)",
|
||||
"descriptionHint": "Shown to the main LLM as the transfer_to_* tool description—keep it short and clear."
|
||||
},
|
||||
"messages": {
|
||||
"loadConfigFailed": "Failed to load config",
|
||||
"loadPersonaFailed": "Failed to load persona list",
|
||||
"nameMissing": "A SubAgent is missing a name",
|
||||
"nameInvalid": "Invalid SubAgent name: only lowercase letters/numbers/underscores, starting with a letter",
|
||||
"nameDuplicate": "Duplicate SubAgent name: {name}",
|
||||
"personaMissing": "SubAgent {name} has no persona selected",
|
||||
"saveSuccess": "Saved successfully",
|
||||
"saveFailed": "Failed to save"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "未来任务管理",
|
||||
"beta": "Beta",
|
||||
"subtitle": "查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。",
|
||||
"proactive": {
|
||||
"supported": "主动发送结果仅支持以下平台:{platforms}",
|
||||
"unsupported": "暂无支持主动消息的平台,请在平台设置中开启。"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"create": "新建任务",
|
||||
"refresh": "刷新",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"submit": "创建"
|
||||
},
|
||||
"table": {
|
||||
"title": "已注册任务",
|
||||
"empty": "暂无任务。",
|
||||
"headers": {
|
||||
"name": "名称",
|
||||
"type": "类型",
|
||||
"cron": "Cron",
|
||||
"nextRun": "下一次执行",
|
||||
"lastRun": "最近执行",
|
||||
"note": "说明",
|
||||
"actions": "操作"
|
||||
},
|
||||
"type": {
|
||||
"once": "一次性",
|
||||
"recurring": "循环",
|
||||
"activeAgent": "Active Agent",
|
||||
"workflow": "Workflow",
|
||||
"unknown": "{type}"
|
||||
},
|
||||
"timezoneLocal": "本地时区",
|
||||
"notAvailable": "—"
|
||||
},
|
||||
"form": {
|
||||
"title": "新建任务",
|
||||
"runOnce": "一次性任务",
|
||||
"name": "任务名称",
|
||||
"note": "任务说明",
|
||||
"cron": "Cron 表达式",
|
||||
"cronPlaceholder": "0 9 * * *",
|
||||
"runAt": "执行时间",
|
||||
"session": "目标 session (platform_id:message_type:session_id)",
|
||||
"timezone": "时区(可选,如 Asia/Shanghai)",
|
||||
"enabled": "启用"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "获取任务失败",
|
||||
"updateFailed": "更新失败",
|
||||
"deleteSuccess": "已删除",
|
||||
"deleteFailed": "删除失败",
|
||||
"sessionRequired": "请填写 session",
|
||||
"noteRequired": "请填写说明",
|
||||
"cronRequired": "请填写 Cron 表达式",
|
||||
"runAtRequired": "请选择执行时间",
|
||||
"createSuccess": "创建成功",
|
||||
"createFailed": "创建失败"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "SubAgent 编排",
|
||||
"beta": "Beta",
|
||||
"subtitle": "主 LLM 只负责聊天与分派(handoff),工具挂载在各个 SubAgent 上。"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "刷新",
|
||||
"save": "保存",
|
||||
"add": "新增 SubAgent",
|
||||
"delete": "删除"
|
||||
},
|
||||
"switches": {
|
||||
"enable": "启用 SubAgent 编排",
|
||||
"dedupe": "主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)"
|
||||
},
|
||||
"description": {
|
||||
"disabled": "不启动:SubAgent 关闭;主 LLM 按 persona 规则挂载工具(默认全部),并直接调用。",
|
||||
"enabled": "启动:主 LLM 会保留自身工具并挂载 transfer_to_* 委派工具。若开启“去重重复工具”,与 SubAgent 指定的工具重叠部分会从主 LLM 工具集中移除。"
|
||||
},
|
||||
"section": {
|
||||
"title": "SubAgents"
|
||||
},
|
||||
"cards": {
|
||||
"statusEnabled": "启用",
|
||||
"statusDisabled": "停用",
|
||||
"unnamed": "未命名 SubAgent",
|
||||
"transferPrefix": "transfer_to_{name}",
|
||||
"switchLabel": "启用",
|
||||
"previewTitle": "预览:主 LLM 将看到的 handoff 工具",
|
||||
"personaChip": "Persona: {id}"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Agent 名称(用于 transfer_to_{name})",
|
||||
"nameHint": "建议使用英文小写+下划线,且全局唯一",
|
||||
"providerLabel": "Chat Provider(可选)",
|
||||
"providerHint": "留空表示跟随全局默认 provider。",
|
||||
"personaLabel": "选择 Persona",
|
||||
"personaHint": "SubAgent 将直接继承所选 Persona 的系统设定与工具。",
|
||||
"descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff)",
|
||||
"descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。"
|
||||
},
|
||||
"messages": {
|
||||
"loadConfigFailed": "获取配置失败",
|
||||
"loadPersonaFailed": "获取 Persona 列表失败",
|
||||
"nameMissing": "存在未填写名称的 SubAgent",
|
||||
"nameInvalid": "SubAgent 名称不合法:仅允许英文小写字母/数字/下划线,且需以字母开头",
|
||||
"nameDuplicate": "SubAgent 名称重复:{name}",
|
||||
"personaMissing": "SubAgent {name} 未选择 Persona",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveFailed": "保存失败"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import zhCNSettings from './locales/zh-CN/features/settings.json';
|
||||
import zhCNAuth from './locales/zh-CN/features/auth.json';
|
||||
import zhCNChart from './locales/zh-CN/features/chart.json';
|
||||
import zhCNDashboard from './locales/zh-CN/features/dashboard.json';
|
||||
import zhCNCron from './locales/zh-CN/features/cron.json';
|
||||
import zhCNAlkaidIndex from './locales/zh-CN/features/alkaid/index.json';
|
||||
import zhCNAlkaidKnowledgeBase from './locales/zh-CN/features/alkaid/knowledge-base.json';
|
||||
import zhCNAlkaidMemory from './locales/zh-CN/features/alkaid/memory.json';
|
||||
@@ -34,6 +35,7 @@ import zhCNKnowledgeBaseDocument from './locales/zh-CN/features/knowledge-base/d
|
||||
import zhCNPersona from './locales/zh-CN/features/persona.json';
|
||||
import zhCNMigration from './locales/zh-CN/features/migration.json';
|
||||
import zhCNCommand from './locales/zh-CN/features/command.json';
|
||||
import zhCNSubagent from './locales/zh-CN/features/subagent.json';
|
||||
|
||||
import zhCNErrors from './locales/zh-CN/messages/errors.json';
|
||||
import zhCNSuccess from './locales/zh-CN/messages/success.json';
|
||||
@@ -63,6 +65,7 @@ import enUSSettings from './locales/en-US/features/settings.json';
|
||||
import enUSAuth from './locales/en-US/features/auth.json';
|
||||
import enUSChart from './locales/en-US/features/chart.json';
|
||||
import enUSDashboard from './locales/en-US/features/dashboard.json';
|
||||
import enUSCron from './locales/en-US/features/cron.json';
|
||||
import enUSAlkaidIndex from './locales/en-US/features/alkaid/index.json';
|
||||
import enUSAlkaidKnowledgeBase from './locales/en-US/features/alkaid/knowledge-base.json';
|
||||
import enUSAlkaidMemory from './locales/en-US/features/alkaid/memory.json';
|
||||
@@ -72,6 +75,7 @@ import enUSKnowledgeBaseDocument from './locales/en-US/features/knowledge-base/d
|
||||
import enUSPersona from './locales/en-US/features/persona.json';
|
||||
import enUSMigration from './locales/en-US/features/migration.json';
|
||||
import enUSCommand from './locales/en-US/features/command.json';
|
||||
import enUSSubagent from './locales/en-US/features/subagent.json';
|
||||
|
||||
import enUSErrors from './locales/en-US/messages/errors.json';
|
||||
import enUSSuccess from './locales/en-US/messages/success.json';
|
||||
@@ -105,6 +109,7 @@ export const translations = {
|
||||
auth: zhCNAuth,
|
||||
chart: zhCNChart,
|
||||
dashboard: zhCNDashboard,
|
||||
cron: zhCNCron,
|
||||
alkaid: {
|
||||
index: zhCNAlkaidIndex,
|
||||
'knowledge-base': zhCNAlkaidKnowledgeBase,
|
||||
@@ -117,7 +122,8 @@ export const translations = {
|
||||
},
|
||||
persona: zhCNPersona,
|
||||
migration: zhCNMigration,
|
||||
command: zhCNCommand
|
||||
command: zhCNCommand,
|
||||
subagent: zhCNSubagent
|
||||
},
|
||||
messages: {
|
||||
errors: zhCNErrors,
|
||||
@@ -151,6 +157,7 @@ export const translations = {
|
||||
auth: enUSAuth,
|
||||
chart: enUSChart,
|
||||
dashboard: enUSDashboard,
|
||||
cron: enUSCron,
|
||||
alkaid: {
|
||||
index: enUSAlkaidIndex,
|
||||
'knowledge-base': enUSAlkaidKnowledgeBase,
|
||||
@@ -163,7 +170,8 @@ export const translations = {
|
||||
},
|
||||
persona: enUSPersona,
|
||||
migration: enUSMigration,
|
||||
command: enUSCommand
|
||||
command: enUSCommand,
|
||||
subagent: enUSSubagent
|
||||
},
|
||||
messages: {
|
||||
errors: enUSErrors,
|
||||
|
||||
@@ -3,58 +3,69 @@
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<div>
|
||||
<div class="d-flex align-center" style="gap: 8px;">
|
||||
<h2 class="text-h5 font-weight-bold">未来任务管理</h2>
|
||||
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>Beta</v-chip>
|
||||
<h2 class="text-h5 font-weight-bold">{{ tm('page.title') }}</h2>
|
||||
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>{{ tm('page.beta') }}</v-chip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。
|
||||
主动发送结果仅支持以下平台:
|
||||
{{ tm('page.subtitle') }}
|
||||
<span v-if="proactivePlatforms.length">
|
||||
{{ proactivePlatforms.map((p) => `${p.display_name || p.name}(${p.id})`).join('、') }}
|
||||
{{ tm('page.proactive.supported', { platforms: proactivePlatformText }) }}
|
||||
</span>
|
||||
<span v-else>暂无支持主动消息的平台,请在平台设置中开启。</span>
|
||||
<span v-else>{{ tm('page.proactive.unsupported') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center" style="gap: 8px;">
|
||||
<v-btn variant="tonal" color="primary" @click="openCreate">新建任务</v-btn>
|
||||
<v-btn variant="tonal" color="primary" :loading="loading" @click="loadJobs">刷新</v-btn>
|
||||
<v-btn variant="tonal" color="primary" @click="openCreate">{{ tm('actions.create') }}</v-btn>
|
||||
<v-btn variant="tonal" color="primary" :loading="loading" @click="loadJobs">{{ tm('actions.refresh') }}</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-card class="rounded-lg" variant="flat">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<div class="text-subtitle-1 font-weight-bold">已注册任务</div>
|
||||
<div class="text-subtitle-1 font-weight-bold">{{ tm('table.title') }}</div>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="!jobs.length && !loading" type="info" variant="tonal">暂无任务。</v-alert>
|
||||
<v-alert v-if="!jobs.length && !loading" type="info" variant="tonal">{{ tm('table.empty') }}</v-alert>
|
||||
|
||||
<v-data-table :items="jobs" :headers="headers" :loading="loading" item-key="job_id" density="comfortable"
|
||||
class="elevation-0">
|
||||
<v-data-table
|
||||
:items="jobs"
|
||||
:headers="headers"
|
||||
:loading="loading"
|
||||
item-key="job_id"
|
||||
density="comfortable"
|
||||
class="elevation-0"
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<div class="font-weight-medium">{{ item.name }}</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ item.description }}</div>
|
||||
</template>
|
||||
<template #item.type="{ item }">
|
||||
<v-chip size="small" :color="item.run_once ? 'orange' : 'primary'" variant="tonal">
|
||||
{{ item.run_once ? '一次性' : (item.job_type || 'active_agent') }}
|
||||
{{ jobTypeLabel(item) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #item.cron_expression="{ item }">
|
||||
<div v-if="item.run_once">{{ formatTime(item.run_at) }}</div>
|
||||
<div v-else>
|
||||
<div>{{ item.cron_expression || '—' }}</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ item.timezone || 'local' }}</div>
|
||||
<div>{{ item.cron_expression || tm('table.notAvailable') }}</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ item.timezone || tm('table.timezoneLocal') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.next_run_time="{ item }">{{ formatTime(item.next_run_time) }}</template>
|
||||
<template #item.last_run_at="{ item }">{{ formatTime(item.last_run_at) }}</template>
|
||||
<template #item.note="{ item }">{{ item.note || '—' }}</template>
|
||||
<template #item.note="{ item }">{{ item.note || tm('table.notAvailable') }}</template>
|
||||
<template #item.actions="{ item }">
|
||||
<div class="d-flex" style="gap: 8px;">
|
||||
<v-switch v-model="item.enabled" inset density="compact" hide-details color="primary"
|
||||
@change="toggleJob(item)" />
|
||||
<v-btn size="small" variant="text" color="primary" @click="deleteJob(item)">删除</v-btn>
|
||||
<v-switch
|
||||
v-model="item.enabled"
|
||||
inset
|
||||
density="compact"
|
||||
hide-details
|
||||
color="primary"
|
||||
@change="toggleJob(item)"
|
||||
/>
|
||||
<v-btn size="small" variant="text" color="primary" @click="deleteJob(item)">{{ tm('actions.delete') }}</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
@@ -67,44 +78,44 @@
|
||||
|
||||
<v-dialog v-model="createDialog" max-width="560">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">新建任务</v-card-title>
|
||||
<v-card-title class="text-h6">{{ tm('form.title') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-switch v-model="newJob.run_once" label="一次性任务" inset color="primary" hide-details />
|
||||
<v-text-field v-model="newJob.name" label="任务名称" variant="outlined" density="comfortable" />
|
||||
<v-text-field v-model="newJob.note" label="任务说明" variant="outlined" density="comfortable" />
|
||||
<v-switch v-model="newJob.run_once" :label="tm('form.runOnce')" inset color="primary" hide-details />
|
||||
<v-text-field v-model="newJob.name" :label="tm('form.name')" variant="outlined" density="comfortable" />
|
||||
<v-text-field v-model="newJob.note" :label="tm('form.note')" variant="outlined" density="comfortable" />
|
||||
<v-text-field
|
||||
v-if="!newJob.run_once"
|
||||
v-model="newJob.cron_expression"
|
||||
label="Cron 表达式"
|
||||
placeholder="0 9 * * *"
|
||||
:label="tm('form.cron')"
|
||||
:placeholder="tm('form.cronPlaceholder')"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="newJob.run_at"
|
||||
label="执行时间"
|
||||
:label="tm('form.runAt')"
|
||||
type="datetime-local"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="newJob.session"
|
||||
label="目标 session (platform_id:message_type:session_id)"
|
||||
:label="tm('form.session')"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="newJob.timezone"
|
||||
label="时区(可选,如 Asia/Shanghai)"
|
||||
:label="tm('form.timezone')"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
/>
|
||||
<v-switch v-model="newJob.enabled" label="启用" inset color="primary" hide-details />
|
||||
<v-switch v-model="newJob.enabled" :label="tm('form.enabled')" inset color="primary" hide-details />
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="createDialog = false">取消</v-btn>
|
||||
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">创建</v-btn>
|
||||
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
|
||||
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">{{ tm('actions.submit') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -112,8 +123,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const { tm } = useModuleI18n('features/cron')
|
||||
|
||||
const loading = ref(false)
|
||||
const jobs = ref<any[]>([])
|
||||
@@ -133,22 +147,26 @@ const newJob = ref({
|
||||
|
||||
const snackbar = ref({ show: false, message: '', color: 'success' })
|
||||
|
||||
const headers = [
|
||||
{ title: '名称', key: 'name', minWidth: '200px' },
|
||||
{ title: '类型', key: 'type', width: 110 },
|
||||
{ title: 'Cron', key: 'cron_expression', minWidth: '160px' },
|
||||
{ title: '下一次执行', key: 'next_run_time', minWidth: '160px' },
|
||||
{ title: '最近执行', key: 'last_run_at', minWidth: '160px' },
|
||||
{ title: '说明', key: 'note', minWidth: '220px' },
|
||||
{ title: '操作', key: 'actions', width: 160, sortable: false }
|
||||
]
|
||||
const proactivePlatformText = computed(() =>
|
||||
proactivePlatforms.value.map((p) => `${p.display_name || p.name}(${p.id})`).join(' / ')
|
||||
)
|
||||
|
||||
const headers = computed(() => [
|
||||
{ title: tm('table.headers.name'), key: 'name', minWidth: '200px' },
|
||||
{ title: tm('table.headers.type'), key: 'type', width: 110 },
|
||||
{ title: tm('table.headers.cron'), key: 'cron_expression', minWidth: '160px' },
|
||||
{ title: tm('table.headers.nextRun'), key: 'next_run_time', minWidth: '160px' },
|
||||
{ title: tm('table.headers.lastRun'), key: 'last_run_at', minWidth: '160px' },
|
||||
{ title: tm('table.headers.note'), key: 'note', minWidth: '220px' },
|
||||
{ title: tm('table.headers.actions'), key: 'actions', width: 160, sortable: false }
|
||||
])
|
||||
|
||||
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
|
||||
snackbar.value = { show: true, message, color }
|
||||
}
|
||||
|
||||
function formatTime(val: any): string {
|
||||
if (!val) return '—'
|
||||
if (!val) return tm('table.notAvailable')
|
||||
try {
|
||||
return new Date(val).toLocaleString()
|
||||
} catch (e) {
|
||||
@@ -156,6 +174,16 @@ function formatTime(val: any): string {
|
||||
}
|
||||
}
|
||||
|
||||
function jobTypeLabel(item: any): string {
|
||||
if (item.run_once) return tm('table.type.once')
|
||||
const type = item.job_type || 'active_agent'
|
||||
const map: Record<string, string> = {
|
||||
active_agent: tm('table.type.activeAgent'),
|
||||
workflow: tm('table.type.workflow')
|
||||
}
|
||||
return map[type] || tm('table.type.unknown', { type })
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -163,10 +191,10 @@ async function loadJobs() {
|
||||
if (res.data.status === 'ok') {
|
||||
jobs.value = Array.isArray(res.data.data) ? res.data.data : []
|
||||
} else {
|
||||
toast(res.data.message || '获取任务失败', 'error')
|
||||
toast(res.data.message || tm('messages.loadFailed'), 'error')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.response?.data?.message || '获取任务失败', 'error')
|
||||
toast(e?.response?.data?.message || tm('messages.loadFailed'), 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -193,11 +221,11 @@ async function toggleJob(job: any) {
|
||||
try {
|
||||
const res = await axios.patch(`/api/cron/jobs/${job.job_id}`, { enabled: job.enabled })
|
||||
if (res.data.status !== 'ok') {
|
||||
toast(res.data.message || '更新失败', 'error')
|
||||
toast(res.data.message || tm('messages.updateFailed'), 'error')
|
||||
await loadJobs()
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.response?.data?.message || '更新失败', 'error')
|
||||
toast(e?.response?.data?.message || tm('messages.updateFailed'), 'error')
|
||||
await loadJobs()
|
||||
}
|
||||
}
|
||||
@@ -206,13 +234,13 @@ async function deleteJob(job: any) {
|
||||
try {
|
||||
const res = await axios.delete(`/api/cron/jobs/${job.job_id}`)
|
||||
if (res.data.status === 'ok') {
|
||||
toast('已删除')
|
||||
toast(tm('messages.deleteSuccess'))
|
||||
jobs.value = jobs.value.filter((j) => j.job_id !== job.job_id)
|
||||
} else {
|
||||
toast(res.data.message || '删除失败', 'error')
|
||||
toast(res.data.message || tm('messages.deleteFailed'), 'error')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.response?.data?.message || '删除失败', 'error')
|
||||
toast(e?.response?.data?.message || tm('messages.deleteFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,19 +264,19 @@ function resetNewJob() {
|
||||
|
||||
async function createJob() {
|
||||
if (!newJob.value.session) {
|
||||
toast('请填写 session', 'warning')
|
||||
toast(tm('messages.sessionRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
if (!newJob.value.note) {
|
||||
toast('请填写说明', 'warning')
|
||||
toast(tm('messages.noteRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
if (!newJob.value.run_once && !newJob.value.cron_expression) {
|
||||
toast('请填写 Cron 表达式', 'warning')
|
||||
toast(tm('messages.cronRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
if (newJob.value.run_once && !newJob.value.run_at) {
|
||||
toast('请选择执行时间', 'warning')
|
||||
toast(tm('messages.runAtRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
@@ -256,15 +284,15 @@ async function createJob() {
|
||||
const payload: any = { ...newJob.value }
|
||||
const res = await axios.post('/api/cron/jobs', payload)
|
||||
if (res.data.status === 'ok') {
|
||||
toast('创建成功')
|
||||
toast(tm('messages.createSuccess'))
|
||||
createDialog.value = false
|
||||
resetNewJob()
|
||||
await loadJobs()
|
||||
} else {
|
||||
toast(res.data.message || '创建失败', 'error')
|
||||
toast(res.data.message || tm('messages.createFailed'), 'error')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.response?.data?.message || '创建失败', 'error')
|
||||
toast(e?.response?.data?.message || tm('messages.createFailed'), 'error')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<div>
|
||||
<div class="d-flex align-center" style="gap: 8px;">
|
||||
<h2 class="text-h5 font-weight-bold">SubAgent 编排</h2>
|
||||
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>Beta</v-chip>
|
||||
<h2 class="text-h5 font-weight-bold">{{ tm('page.title') }}</h2>
|
||||
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>{{ tm('page.beta') }}</v-chip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
主 LLM 只负责聊天与分派(handoff),工具挂载在各个 SubAgent 上。
|
||||
{{ tm('page.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center" style="gap: 8px;">
|
||||
<v-btn variant="tonal" color="primary" :loading="loading" @click="reload">刷新</v-btn>
|
||||
<v-btn variant="flat" color="primary" :loading="saving" @click="save">保存</v-btn>
|
||||
<v-btn variant="tonal" color="primary" :loading="loading" @click="reload">{{ tm('actions.refresh') }}</v-btn>
|
||||
<v-btn variant="flat" color="primary" :loading="saving" @click="save">{{ tm('actions.save') }}</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,30 +21,36 @@
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-switch v-model="cfg.main_enable" label="启用 SubAgent 编排"
|
||||
inset color="primary" hide-details density="comfortable" />
|
||||
<v-switch
|
||||
v-model="cfg.main_enable"
|
||||
:label="tm('switches.enable')"
|
||||
inset
|
||||
color="primary"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-switch v-model="cfg.remove_main_duplicate_tools" :disabled="!cfg.main_enable"
|
||||
label="主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)"
|
||||
inset color="primary" hide-details density="comfortable" />
|
||||
<v-switch
|
||||
v-model="cfg.remove_main_duplicate_tools"
|
||||
:disabled="!cfg.main_enable"
|
||||
:label="tm('switches.dedupe')"
|
||||
inset
|
||||
color="primary"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
<div v-if="!cfg.main_enable">
|
||||
不启动:SubAgent 关闭;主 LLM 按 persona 规则挂载工具(默认全部),并直接调用。
|
||||
</div>
|
||||
<div v-else>
|
||||
启动:主 LLM 会保留自身工具并挂载 transfer_to_* 委派工具。
|
||||
若开启“去重重复工具”,与 SubAgent 指定的工具重叠部分会从主 LLM 工具集中移除。
|
||||
</div>
|
||||
{{ mainStateDescription }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mt-6 mb-2">
|
||||
<div class="text-subtitle-1 font-weight-bold">SubAgents</div>
|
||||
<div class="text-subtitle-1 font-weight-bold">{{ tm('section.title') }}</div>
|
||||
<v-btn size="small" variant="tonal" color="primary" @click="addAgent">
|
||||
新增 SubAgent
|
||||
{{ tm('actions.add') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
@@ -54,23 +60,31 @@
|
||||
<div class="subagent-panel-title">
|
||||
<div class="subagent-title-left">
|
||||
<v-chip :color="agent.enabled ? 'success' : 'grey'" size="small" variant="tonal">
|
||||
{{ agent.enabled ? '启用' : '停用' }}
|
||||
{{ agent.enabled ? tm('cards.statusEnabled') : tm('cards.statusDisabled') }}
|
||||
</v-chip>
|
||||
|
||||
<div class="subagent-title-text">
|
||||
<div class="subagent-title-name">{{ agent.name || '未命名 SubAgent' }}</div>
|
||||
<div class="subagent-title-sub">transfer_to_{{ agent.name || '...' }}</div>
|
||||
<div class="subagent-title-name">{{ agent.name || tm('cards.unnamed') }}</div>
|
||||
<div class="subagent-title-sub">
|
||||
{{ tm('cards.transferPrefix', { name: agent.name || '...' }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subagent-title-right">
|
||||
<v-switch v-model="agent.enabled" inset color="primary" hide-details class="subagent-enabled-inline"
|
||||
@click.stop>
|
||||
<template #label>启用</template>
|
||||
<v-switch
|
||||
v-model="agent.enabled"
|
||||
inset
|
||||
color="primary"
|
||||
hide-details
|
||||
class="subagent-enabled-inline"
|
||||
@click.stop
|
||||
>
|
||||
<template #label>{{ tm('cards.switchLabel') }}</template>
|
||||
</v-switch>
|
||||
|
||||
<v-btn size="small" variant="text" color="error" @click.stop="removeAgent(idx)">
|
||||
删除
|
||||
{{ tm('actions.delete') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,42 +93,69 @@
|
||||
<v-expansion-panel-text>
|
||||
<v-row class="subagent-grid">
|
||||
<v-col cols="12" md="5">
|
||||
<v-text-field v-model="agent.name" label="Agent 名称(用于 transfer_to_{name})" variant="outlined"
|
||||
density="comfortable" hint="建议使用英文小写+下划线,且全局唯一" persistent-hint />
|
||||
<v-text-field
|
||||
v-model="agent.name"
|
||||
:label="tm('form.nameLabel')"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:hint="tm('form.nameHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="7" class="subagent-actions">
|
||||
<ProviderSelector v-model="agent.provider_id" provider-type="chat_completion"
|
||||
label="Chat Provider(可选)" hint="留空表示跟随全局默认 provider。" persistent-hint clearable
|
||||
class="subagent-provider" />
|
||||
<ProviderSelector
|
||||
v-model="agent.provider_id"
|
||||
provider-type="chat_completion"
|
||||
:label="tm('form.providerLabel')"
|
||||
:hint="tm('form.providerHint')"
|
||||
persistent-hint
|
||||
clearable
|
||||
class="subagent-provider"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-autocomplete v-model="agent.persona_id" :items="personaOptions" item-title="title"
|
||||
item-value="value" label="选择 Persona" variant="outlined" density="comfortable" clearable
|
||||
:loading="personaLoading" :disabled="personaLoading" hint="SubAgent 将直接继承所选 Persona 的系统设定与工具。"
|
||||
persistent-hint />
|
||||
<v-autocomplete
|
||||
v-model="agent.persona_id"
|
||||
:items="personaOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="tm('form.personaLabel')"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:loading="personaLoading"
|
||||
:disabled="personaLoading"
|
||||
:hint="tm('form.personaHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field v-model="agent.public_description" label="对主 LLM 的描述(用于决定是否 handoff)" variant="outlined"
|
||||
density="comfortable" hint="这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。" persistent-hint />
|
||||
<v-text-field
|
||||
v-model="agent.public_description"
|
||||
:label="tm('form.descriptionLabel')"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:hint="tm('form.descriptionHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="text-caption text-medium-emphasis">预览:主 LLM 将看到的 handoff 工具</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ tm('cards.previewTitle') }}</div>
|
||||
<div class="d-flex align-center" style="gap: 8px; flex-wrap: wrap;">
|
||||
<v-chip size="small" variant="outlined" color="primary">
|
||||
transfer_to_{{ agent.name || '...' }}
|
||||
{{ tm('cards.transferPrefix', { name: agent.name || '...' }) }}
|
||||
</v-chip>
|
||||
<v-chip size="small" variant="tonal" color="secondary" v-if="agent.persona_id">
|
||||
Persona: {{ agent.persona_id }}
|
||||
{{ tm('cards.personaChip', { id: agent.persona_id }) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
@@ -125,9 +166,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import ProviderSelector from '@/components/shared/ProviderSelector.vue'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
type SubAgentItem = {
|
||||
__key: string
|
||||
@@ -144,6 +186,8 @@ type SubAgentConfig = {
|
||||
agents: SubAgentItem[]
|
||||
}
|
||||
|
||||
const { tm } = useModuleI18n('features/subagent')
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
@@ -166,6 +210,10 @@ const cfg = ref<SubAgentConfig>({
|
||||
const personaOptions = ref<{ title: string; value: string }[]>([])
|
||||
const personaLoading = ref(false)
|
||||
|
||||
const mainStateDescription = computed(() =>
|
||||
cfg.value.main_enable ? tm('description.enabled') : tm('description.disabled')
|
||||
)
|
||||
|
||||
function normalizeConfig(raw: any): SubAgentConfig {
|
||||
const main_enable = !!raw?.main_enable
|
||||
const remove_main_duplicate_tools = !!raw?.remove_main_duplicate_tools
|
||||
@@ -176,15 +224,14 @@ function normalizeConfig(raw: any): SubAgentConfig {
|
||||
const persona_id = (a?.persona_id ?? '').toString()
|
||||
const public_description = (a?.public_description ?? '').toString()
|
||||
const enabled = a?.enabled !== false
|
||||
const provider_id = (a?.provider_id ?? undefined) as (string | undefined)
|
||||
const provider_id = (a?.provider_id ?? undefined) as string | undefined
|
||||
|
||||
return {
|
||||
__key: `${Date.now()}_${i}_${Math.random().toString(16).slice(2)}`,
|
||||
name,
|
||||
persona_id,
|
||||
public_description,
|
||||
enabled
|
||||
,
|
||||
enabled,
|
||||
provider_id
|
||||
}
|
||||
})
|
||||
@@ -199,10 +246,10 @@ async function loadConfig() {
|
||||
if (res.data.status === 'ok') {
|
||||
cfg.value = normalizeConfig(res.data.data)
|
||||
} else {
|
||||
toast(res.data.message || '获取配置失败', 'error')
|
||||
toast(res.data.message || tm('messages.loadConfigFailed'), 'error')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.response?.data?.message || '获取配置失败', 'error')
|
||||
toast(e?.response?.data?.message || tm('messages.loadConfigFailed'), 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -220,7 +267,7 @@ async function loadPersonas() {
|
||||
}))
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.response?.data?.message || '获取 Persona 列表失败', 'error')
|
||||
toast(e?.response?.data?.message || tm('messages.loadPersonaFailed'), 'error')
|
||||
} finally {
|
||||
personaLoading.value = false
|
||||
}
|
||||
@@ -247,20 +294,20 @@ function validateBeforeSave(): boolean {
|
||||
for (const a of cfg.value.agents) {
|
||||
const name = (a.name || '').trim()
|
||||
if (!name) {
|
||||
toast('存在未填写名称的 SubAgent', 'warning')
|
||||
toast(tm('messages.nameMissing'), 'warning')
|
||||
return false
|
||||
}
|
||||
if (!nameRe.test(name)) {
|
||||
toast('SubAgent 名称不合法:仅允许英文小写字母/数字/下划线,且需以字母开头', 'warning')
|
||||
toast(tm('messages.nameInvalid'), 'warning')
|
||||
return false
|
||||
}
|
||||
if (seen.has(name)) {
|
||||
toast(`SubAgent 名称重复:${name}`, 'warning')
|
||||
toast(tm('messages.nameDuplicate', { name }), 'warning')
|
||||
return false
|
||||
}
|
||||
seen.add(name)
|
||||
if (!a.persona_id) {
|
||||
toast(`SubAgent ${name} 未选择 Persona`, 'warning')
|
||||
toast(tm('messages.personaMissing', { name }), 'warning')
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -271,11 +318,10 @@ async function save() {
|
||||
if (!validateBeforeSave()) return
|
||||
saving.value = true
|
||||
try {
|
||||
// Strip UI-only fields
|
||||
const payload = {
|
||||
main_enable: cfg.value.main_enable,
|
||||
remove_main_duplicate_tools: cfg.value.remove_main_duplicate_tools,
|
||||
agents: cfg.value.agents.map(a => ({
|
||||
agents: cfg.value.agents.map((a) => ({
|
||||
name: a.name,
|
||||
persona_id: a.persona_id,
|
||||
public_description: a.public_description,
|
||||
@@ -286,12 +332,12 @@ async function save() {
|
||||
|
||||
const res = await axios.post('/api/subagent/config', payload)
|
||||
if (res.data.status === 'ok') {
|
||||
toast(res.data.message || '保存成功', 'success')
|
||||
toast(res.data.message || tm('messages.saveSuccess'), 'success')
|
||||
} else {
|
||||
toast(res.data.message || '保存失败', 'error')
|
||||
toast(res.data.message || tm('messages.saveFailed'), 'error')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.response?.data?.message || '保存失败', 'error')
|
||||
toast(e?.response?.data?.message || tm('messages.saveFailed'), 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user