项目文件夹

文件
2026-07-13 13:24:55 +08:00

3476 行
188 KiB
HTML

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coze Studio 管理后台</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f7fa;
color: #2c3e50;
}
.admin-container {
display: flex;
height: 100vh;
}
.sidebar {
width: 250px;
background: #2c3e50;
color: white;
padding: 0;
box-shadow: 2px 0 10px rgba(0,0,0,0.1);
position: relative;
overflow-y: auto;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
color: #ecf0f1;
}
.nav-menu {
list-style: none;
padding: 0;
margin: 0;
}
.nav-item {
margin: 0;
}
.nav-link {
display: flex;
align-items: center;
padding: 12px 20px;
color: #bdc3c7;
text-decoration: none;
transition: all 0.3s ease;
cursor: pointer;
font-size: 16px;
position: relative;
}
.nav-link:hover {
background: rgba(52, 152, 219, 0.1);
color: #3498db;
}
.nav-link.active {
background: #3498db;
color: white;
}
.nav-icon {
margin-right: 12px;
font-size: 16px;
width: 20px;
text-align: center;
}
.nav-arrow {
margin-left: auto;
font-size: 12px;
transition: transform 0.3s ease;
}
.has-submenu .nav-arrow {
transform: rotate(-90deg);
}
.has-submenu.expanded .nav-arrow {
transform: rotate(0deg);
}
.submenu {
list-style: none;
padding: 0;
margin: 0;
background: rgba(0, 0, 0, 0.1);
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.has-submenu.expanded .submenu {
max-height: 200px;
}
.submenu-item {
margin: 0;
}
.submenu-link {
display: flex;
align-items: center;
padding: 10px 20px 10px 52px;
color: #95a5a6;
text-decoration: none;
transition: all 0.3s ease;
cursor: pointer;
font-size: 15px;
}
.submenu-link:hover {
background: rgba(52, 152, 219, 0.1);
color: #3498db;
}
.submenu-link.active {
background: #3498db;
color: white;
}
.submenu-icon {
margin-right: 8px;
font-size: 14px;
width: 16px;
text-align: center;
}
.main-content {
flex: 1;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.top-bar {
background: white;
padding: 20px 30px;
border-bottom: 1px solid #e1e8ed;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.page-header {
margin: 0;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: #2c3e50;
margin: 0 0 5px 0;
}
.page-description {
color: #6c757d;
font-size: 16px;
margin: 0;
}
.content-area {
flex: 1;
padding: 30px;
overflow-y: auto;
background: #f8f9fa;
}
.content-frame {
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
min-height: calc(100vh - 200px);
overflow: hidden;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
color: #6c757d;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
text-align: center;
padding: 40px;
color: #dc3545;
}
.error-icon {
font-size: 48px;
margin-bottom: 15px;
}
.sidebar-footer {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
padding: 0 25px;
border-top: 1px solid rgba(255,255,255,0.2);
padding-top: 20px;
}
.user-info {
display: flex;
align-items: center;
color: rgba(255,255,255,0.8);
font-size: 16px;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255,255,255,0.2);
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
font-size: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.sidebar {
width: 200px;
}
.content-area {
padding: 20px;
}
.top-bar {
padding: 15px 20px;
}
}
.welcome-content {
text-align: center;
padding: 60px 20px;
}
.welcome-icon {
font-size: 64px;
color: #667eea;
margin-bottom: 20px;
}
.welcome-title {
font-size: 24px;
color: #2c3e50;
margin-bottom: 15px;
}
.welcome-text {
color: #7f8c8d;
font-size: 16px;
line-height: 1.6;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 30px;
}
.stat-card {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
}
.stat-number {
font-size: 32px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
/* 透明浮层样式(合并自 config.html */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10001; /* 保证高于所有模态层(9999) */
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.overlay.show {
opacity: 1;
visibility: visible;
}
.overlay-content {
background: white;
padding: 24px 32px;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 400px;
text-align: center;
transform: scale(0.9);
transition: transform 0.3s ease;
}
.overlay.show .overlay-content {
transform: scale(1);
}
.overlay-content.success {
border-left: 4px solid #16a34a;
color: #16a34a;
}
.overlay-content.error {
border-left: 4px solid #dc2626;
color: #dc2626;
}
.overlay-content .icon {
font-size: 48px;
margin-bottom: 16px;
}
.overlay-content.success .icon::before {
content: "✓";
}
.overlay-content.error .icon::before {
content: "✗";
}
.overlay-content .message {
font-size: 16px;
font-weight: 500;
line-height: 1.5;
}
.action-buttons {
margin-top: 30px;
padding-top: 24px;
border-top: 2px solid #f1f3f4;
text-align: center;
}
</style>
</head>
<body>
<div class="admin-container">
<nav class="sidebar">
<div class="sidebar-header">
<div class="logo">
<div class="logo-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1" fill="#4A90E2"/>
<rect x="14" y="3" width="7" height="7" rx="1" fill="#4A90E2"/>
<rect x="3" y="14" width="7" height="7" rx="1" fill="#4A90E2"/>
<rect x="14" y="14" width="7" height="7" rx="1" fill="#4A90E2"/>
</svg>
</div>
<span class="logo-text">Coze Studio</span>
</div>
</div>
<ul class="nav-menu">
<li class="nav-item has-submenu">
<a href="#" class="nav-link" data-page="config">
<span class="nav-icon">⚙️</span>
配置管理
<span class="nav-arrow"></span>
</a>
<ul class="submenu">
<li class="submenu-item">
<a href="#" class="submenu-link active" data-page="basic-config">
<span class="submenu-icon">🔧</span>
基础配置
</a>
</li>
<li class="submenu-item">
<a href="#" class="submenu-link" data-page="model-management">
<span class="submenu-icon">🤖</span>
模型管理
</a>
</li>
<li class="submenu-item">
<a href="#" class="submenu-link" data-page="knowledge-config">
<span class="submenu-icon">📚</span>
知识库配置
</a>
</li>
</ul>
</li>
</ul>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar" id="userAvatar">👤</div>
<div>
<div id="userName">管理员</div>
<div id="userStatus" style="font-size: 12px; opacity: 0.7;">在线</div>
</div>
</div>
</div>
</nav>
<main class="main-content">
<div class="top-bar" style="display: none;">
<div class="page-header">
<h1 class="page-title" id="pageTitle">基础配置</h1>
<p class="page-description" id="pageDescription">管理和配置基础配置</p>
</div>
</div>
<div class="content-area">
<div class="content-frame">
<div id="pageContent">
<div class="loading">
<div class="loading-spinner"></div>
<span>${t('loading')}</span>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 全局透明浮层(来自 config.html -->
<div id="overlay" class="overlay">
<div id="overlayContent" class="overlay-content">
<div class="icon"></div>
<div class="message" id="overlayMessage"></div>
</div>
</div>
<script>
// i18n(简易字典 + 应用函数)
const I18N = {
'zh-CN': {
'nav.config': '配置管理',
'nav.basic': '基础配置',
'nav.model': '模型管理',
'nav.knowledge': '知识库配置',
'page.basic.title': '基础配置',
'page.basic.desc': '系统基础配置管理',
'page.model.title': '模型管理',
'page.model.desc': 'AI模型配置和管理',
'page.knowledge.title': '知识库配置',
'page.knowledge.desc': '知识库设置和管理',
'page.default.title': '页面',
'page.default.desc': '页面描述',
'page.contentArea': '这里是{title}页面的内容区域。',
'loading': '加载中...',
'status.online': '在线',
'error.generic': '发生错误',
'success.generic': '操作成功',
'quick.title': '快速操作',
'quick.new': '新建',
'quick.import': '导入',
'quick.export': '导出',
'quick.settings': '设置',
'feature.module1': '功能模块 1',
'feature.module2': '功能模块 2',
'feature.module3': '功能模块 3',
'feature.desc': '相关功能描述和操作入口',
'user.admin': '管理员',
'basic.loading': '正在加载配置...',
'basic.label.admin_emails': '管理员邮箱',
'basic.placeholder.email_input': '输入邮箱,按回车添加',
'basic.placeholder.coze_api_token': '输入您的 Coze API Token',
'basic.help.coze_api_token_apply': '前往此链接申请 Coze API Token',
'basic.label.enable_coze_saas': '启用 Coze SaaS 插件',
'basic.label.disable_user_registration': '禁止用户注册',
'basic.label.whitelist_emails': '允许注册的白名单邮箱',
'basic.help.whitelist': '当禁止用户注册时,只有白名单中的邮箱才能注册',
'basic.label.python_env': 'Python 代码运行环境(修改后需要重启服务生效)',
'basic.help.local_risk': 'Local 有被攻击的安全风险,生产环境慎用,建议使用 Sandbox 模式。',
'basic.sandbox.title': 'Sandbox 配置',
'basic.label.allow_env': '允许读取的环境变量',
'basic.label.allow_read': '允许读取的目录地址',
'basic.label.allow_write': '允许写的目录地址',
'basic.label.allow_run': '允许执行的命令',
'basic.label.allow_net': '允许访问的网络地址',
'basic.label.allow_ffi': '允许访问的 lib 库',
'basic.label.node_modules_dir': 'Node Modules 目录地址',
'basic.label.timeout_seconds': '代码执行超时时间(秒)',
'basic.label.memory_limit_mb': '内存限制(MB',
'server.title': '服务器配置',
'server.host.label': '服务器主机地址',
'server.host.help': '格式:http(s)://主机名:端口号 , oauth 鉴权会用到',
'btn.save': '保存配置',
'btn.reload': '重新加载',
'error.invalid_email': '请输入有效的邮箱地址',
'error.email_exists': '该邮箱已存在',
'error.admin_email_required': '请至少设置一个管理员邮箱',
'error.server_host_required': '请填写服务器主机地址',
'error.invalid_admin_emails': '管理员邮箱存在无效地址:{list}',
'error.invalid_whitelist_emails': '白名单邮箱存在无效地址:{list}',
'error.save_failed': '保存失败',
'error.save_failed_json': '保存失败:请检查服务是否启动并返回有效 JSON',
'error.load_failed_empty': '加载失败:返回数据为空',
'error.load_failed_json': '加载失败:请检查服务是否启动并返回有效 JSON',
'success.saved': '保存成功',
'success.saved_restart_required': '保存成功,服务需要重启生效',
'overlay.saving': '正在保存配置...',
'error.missing_required': '请填写必填项:{list}',
'error.api_error': '接口返回错误',
'error.save_failed_reason': '保存失败: {reason}',
'page.manage_config': '管理和配置 {title}',
'page.empty': '页面内容为空',
'help.title': '帮助文档',
'help.quickstart.title': '🚀 快速开始',
'help.quickstart.step1': '首先配置基础设置,包括 API 密钥和系统参数',
'help.quickstart.step2': '添加和配置 AI 模型',
'help.quickstart.step3': '创建知识库并上传文档',
'help.quickstart.step4': '开始使用您的 AI 助手',
'help.features.title': '📚 功能说明',
'help.features.basic': '基础配置:系统设置、API 配置、安全设置',
'help.features.model': '模型管理:添加、编辑、删除 AI 模型',
'help.features.kb': '知识库:文档上传、索引管理、检索配置',
'help.support.title': '🔧 技术支持',
'help.support.contact': '如果您遇到问题,请联系技术支持团队',
'help.support.email': '📧 邮箱: {email}',
'help.support.phone': '📞 电话: {phone}',
'export.success': '配置文件已导出!',
'error.page_load_failed_reason': '页面加载失败: {reason}',
// 通用
'common.cancel': '取消',
'common.save': '保存',
'common.delete': '删除',
'common.enabled': '启用',
'common.disabled': '停用',
'common.on': '开启',
'common.off': '关闭',
'common.saving': '保存中...',
'common.not_configured': '未配置',
'common.unknown_error': '未知错误',
// 模型管理
'model.list.title': '模型列表',
'model.provider.count': '提供方数量:{count}',
'model.provider.model_count': '模型数量:{count}',
'model.actions.add': '添加模型',
'model.provider.unnamed': '未命名提供方',
'model.provider.unknown': '未知提供方',
'model.unnamed': '未命名模型',
'model.field.id': 'ID',
'model.field.model': 'Model',
'model.field.api_key': 'API Key',
'model.field.base64_url': 'Base64URL',
'model.field.region': 'Region',
'model.field.backend': 'Backend',
'model.field.project': 'Project',
'model.field.location': 'Location',
'model.field.azure': 'By Azure',
'model.field.api_version': 'API Version',
'model.field.endpoint': '接口地址',
'model.field.thinking_type': 'Thinking Type',
'model.thinking_type.default': 'Default',
'model.thinking_type.enable': 'Enable',
'model.thinking_type.disable': 'Disable',
'model.thinking_type.auto': 'Auto',
'model.error.load_list_failed': '加载模型列表失败',
'model.error.render_page_failed': '渲染模型页面失败',
'model.error.delete_missing_id': '删除模型失败: 缺少模型ID',
'model.confirm.delete': '确认删除该模型?删除后不可恢复',
'model.error.delete_invalid_json': '删除模型失败: 返回结果不是有效 JSON',
'model.error.delete_failed': '删除模型失败',
'model.error.open_modal_failed': '无法打开添加模型表单:组件未渲染',
'model.error.provider_unrecognized': '无法识别模型提供方,请从提供方卡片点击“添加模型”。',
'model.error.missing_required_with_api': '请填写必填项:模型展示名称、model、api_key',
'model.error.missing_required': '请填写必填项:模型展示名称、model',
'model.error.create_read_failed': '创建模型失败: 无法读取响应内容',
'model.create.success': '创建成功',
'model.error.create_failed': '创建模型失败',
// 添加模型弹窗
'model.modal.add.title': '添加模型',
'model.modal.display_name.label': '模型显示名称',
'model.modal.display_name.placeholder': '例如:Doubao Seed 1.6',
'model.modal.model.label': 'Model',
'model.modal.model.placeholder': '例如:doubao-seed-1-6-250615',
'model.modal.api_key.label': 'API Key',
'model.modal.api_key.placeholder': '密钥',
'model.modal.base_url.label': 'Base URL',
'model.modal.base_url.placeholder': '选填',
'model.modal.enable_base64_url.label': '启用 Base64 URL',
'model.modal.enable_base64_url.help': '使用 base64 编码图片、文件、音频、视频 URL 给模型',
'model.modal.ark_region.label': 'RegionArk',
'model.modal.ark_region.placeholder': '选填,例如:cn-beijing',
'model.modal.azure.label': '使用 Azure',
'model.modal.azure.help': 'OpenAI 设置:勾选表示通过 Azure 使用',
'model.modal.openai_api_version.label': 'OpenAI API Version',
'model.modal.openai_api_version.placeholder': '选填,例如:2024-06-01',
'model.modal.gemini_backend.label': 'Backend',
'model.modal.gemini_backend.unspecified': '未指定',
'model.modal.gemini_backend.gemini_api': 'Gemini API',
'model.modal.gemini_backend.vertex_ai': 'Vertex AI',
'model.modal.gemini_project.label': 'Project',
'model.modal.gemini_project.placeholder': '选填,例如:my-project',
'model.modal.gemini_location.label': 'Location',
'model.modal.gemini_location.placeholder': '选填,例如:us-central1',
'model.modal.thinking_type.label': 'Thinking Type',
'model.modal.thinking_type.enable': 'Enable',
'model.modal.thinking_type.disable': 'Disable',
'model.modal.thinking_type.auto': 'Auto',
// 知识库配置
'knowledge.error.load_failed_json': '加载知识库配置失败: 返回结果不是有效 JSON',
'knowledge.error.load_failed_msg': '加载知识库配置失败: {msg}',
'knowledge.error.load_failed_empty': '加载知识库配置失败: 返回数据为空',
'knowledge.error.load_failed': '加载知识库配置失败',
'knowledge.error.load_failed_reason': '加载知识库配置失败: {reason}',
'knowledge.log.local_preview_no_demo': '本地预览:不使用示例数据',
'knowledge.section.embedding': 'Embedding 配置',
'knowledge.connection': '{name} 连接',
'knowledge.section.rerank': 'Rerank 配置',
'knowledge.section.ocr': 'OCR 配置',
'knowledge.section.parser': 'Parser 配置',
'knowledge.section.builtin_model': '知识库内置模型配置',
'knowledge.label.choose_builtin_model': '选择内置模型',
'knowledge.placeholder.select_model': '请选择模型',
'knowledge.loading.model_list': '正在加载模型列表...',
'knowledge.empty.model_list': '未获取到模型列表',
'knowledge.placeholder.model_fallback': '模型 #{id}',
'knowledge.tip.setup_model_first': '请先配置模型',
'knowledge.error.model_list_load_failed': '加载模型列表失败',
'knowledge.error.render_failed': '渲染知识库配置失败',
'knowledge.error.save_failed': '保存配置失败'
},
'en-US': {
'nav.config': 'Configuration',
'nav.basic': 'Basic Config',
'nav.model': 'Model Management',
'nav.knowledge': 'Knowledge Config',
'page.basic.title': 'Basic Config',
'page.basic.desc': 'System basic configuration',
'page.model.title': 'Model Management',
'page.model.desc': 'AI model configuration and management',
'page.knowledge.title': 'Knowledge Config',
'page.knowledge.desc': 'Knowledge base settings and management',
'page.default.title': 'Page',
'page.default.desc': 'Page Description',
'page.contentArea': 'This is the content area of {title}.',
'loading': 'Loading...',
'status.online': 'Online',
'error.generic': 'Error occurred',
'success.generic': 'Success',
'quick.title': 'Quick Actions',
'quick.new': 'New',
'quick.import': 'Import',
'quick.export': 'Export',
'quick.settings': 'Settings',
'feature.module1': 'Feature Module 1',
'feature.module2': 'Feature Module 2',
'feature.module3': 'Feature Module 3',
'feature.desc': 'Feature description and actions',
'user.admin': 'Admin',
'basic.loading': 'Loading configuration...',
'basic.label.admin_emails': 'Admin Emails',
'basic.placeholder.email_input': 'Type email, press Enter to add',
'basic.placeholder.coze_api_token': 'Enter your Coze API Token',
'basic.help.coze_api_token_apply': 'Apply for a Coze API Token here',
'basic.label.enable_coze_saas': 'Enable Coze SaaS plugin',
'basic.label.disable_user_registration': 'Disable user registration',
'basic.label.whitelist_emails': 'Whitelist emails allowed to register',
'basic.help.whitelist': 'When registration is disabled, only whitelisted emails can register.',
'basic.label.python_env': 'Python Execution Environment(Change requires service restart)',
'basic.help.local_risk': 'Local has security risks; use Sandbox in production.',
'basic.sandbox.title': 'Sandbox Config',
'basic.label.allow_env': 'Allowed environment variables',
'basic.label.allow_read': 'Readable directories',
'basic.label.allow_write': 'Writable directories',
'basic.label.allow_run': 'Allowed commands',
'basic.label.allow_net': 'Allowed network addresses',
'basic.label.allow_ffi': 'Allowed lib (FFI)',
'basic.label.node_modules_dir': 'Node Modules directory',
'basic.label.timeout_seconds': 'Code timeout (seconds)',
'basic.label.memory_limit_mb': 'Memory limit (MB)',
'server.title': 'Server Configuration',
'server.host.label': 'Server host address',
'server.host.help': 'Format: http(s)://hostname:port , used by oauth auth',
'btn.save': 'Save Config',
'btn.reload': 'Reload',
'error.invalid_email': 'Please enter a valid email address',
'error.email_exists': 'This email already exists',
'error.admin_email_required': 'Please set at least one admin email',
'error.server_host_required': 'Please provide the server host address',
'error.invalid_admin_emails': 'Invalid admin emails: {list}',
'error.invalid_whitelist_emails': 'Invalid whitelist emails: {list}',
'error.save_failed': 'Save failed',
'error.save_failed_json': 'Save failed: check the service and JSON response',
'error.load_failed_empty': 'Load failed: empty response data',
'error.load_failed_json': 'Load failed: check the service and JSON response',
'success.saved': 'Saved successfully',
'success.saved_restart_required': 'Saved successfully. Restart the service to take effect',
'overlay.saving': 'Saving configuration...',
'error.missing_required': 'Please fill required fields: {list}',
'error.api_error': 'API returned error',
'error.save_failed_reason': 'Save failed: {reason}',
'page.manage_config': 'Manage and configure {title}',
'page.empty': 'Page content is empty',
'help.title': 'Help',
'help.quickstart.title': '🚀 Quick Start',
'help.quickstart.step1': 'Configure basic settings including API keys and system parameters',
'help.quickstart.step2': 'Add and configure AI models',
'help.quickstart.step3': 'Create a knowledge base and upload documents',
'help.quickstart.step4': 'Start using your AI assistant',
'help.features.title': '📚 Feature Overview',
'help.features.basic': 'Basic Config: system settings, API config, security settings',
'help.features.model': 'Model Management: add, edit, and delete AI models',
'help.features.kb': 'Knowledge Base: document upload, indexing, retrieval config',
'help.support.title': '🔧 Technical Support',
'help.support.contact': 'If you encounter issues, contact the support team',
'help.support.email': '📧 Email: {email}',
'help.support.phone': '📞 Phone: {phone}',
'export.success': 'Configuration file exported!',
'error.page_load_failed_reason': 'Page load failed: {reason}',
// Common
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.delete': 'Delete',
'common.enabled': 'Enabled',
'common.disabled': 'Disabled',
'common.on': 'On',
'common.off': 'Off',
'common.saving': 'Saving...',
'common.not_configured': 'Not configured',
'common.unknown_error': 'Unknown error',
// Model Management
'model.list.title': 'Model List',
'model.provider.count': 'Providers: {count}',
'model.provider.model_count': 'Model count: {count}',
'model.actions.add': 'Add Model',
'model.provider.unnamed': 'Unnamed provider',
'model.provider.unknown': 'Unknown provider',
'model.unnamed': 'Unnamed model',
'model.field.id': 'ID',
'model.field.model': 'Model',
'model.field.api_key': 'API Key',
'model.field.base64_url': 'Base64URL',
'model.field.region': 'Region',
'model.field.backend': 'Backend',
'model.field.project': 'Project',
'model.field.location': 'Location',
'model.field.azure': 'By Azure',
'model.field.api_version': 'API Version',
'model.field.endpoint': 'Endpoint',
'model.field.thinking_type': 'Thinking Type',
'model.thinking_type.default': 'Default',
'model.thinking_type.enable': 'Enable',
'model.thinking_type.disable': 'Disable',
'model.thinking_type.auto': 'Auto',
'model.error.load_list_failed': 'Failed to load model list',
'model.error.render_page_failed': 'Failed to render model page',
'model.error.delete_missing_id': 'Delete model failed: missing model ID',
'model.confirm.delete': 'Delete this model? This cannot be undone',
'model.error.delete_invalid_json': 'Delete model failed: invalid JSON response',
'model.error.delete_failed': 'Delete model failed',
'model.error.open_modal_failed': 'Cannot open add-model form: component not rendered',
'model.error.provider_unrecognized': 'Unrecognized provider; click “Add Model” on a provider card.',
'model.error.missing_required_with_api': 'Please fill required: display name, model, api_key',
'model.error.missing_required': 'Please fill required: display name, model',
'model.error.create_read_failed': 'Create model failed: cannot read response',
'model.create.success': 'Created successfully',
'model.error.create_failed': 'Create model failed',
// Add Model Modal
'model.modal.add.title': 'Add Model',
'model.modal.display_name.label': 'Display Name',
'model.modal.display_name.placeholder': 'e.g., Doubao Seed 1.6',
'model.modal.model.label': 'Model',
'model.modal.model.placeholder': 'e.g., doubao-seed-1-6-250615',
'model.modal.api_key.label': 'API Key',
'model.modal.api_key.placeholder': 'Secret',
'model.modal.base_url.label': 'Base URL',
'model.modal.base_url.placeholder': 'Optional',
'model.modal.enable_base64_url.label': 'Enable Base64 URL',
'model.modal.enable_base64_url.help': 'Use base64-encoded URLs for images, files, audio, video',
'model.modal.ark_region.label': 'Region (Ark)',
'model.modal.ark_region.placeholder': 'Optional, e.g., cn-beijing',
'model.modal.azure.label': 'Use Azure',
'model.modal.azure.help': 'OpenAI settings: check to use via Azure',
'model.modal.openai_api_version.label': 'OpenAI API Version',
'model.modal.openai_api_version.placeholder': 'Optional, e.g., 2024-06-01',
'model.modal.gemini_backend.label': 'Backend',
'model.modal.gemini_backend.unspecified': 'Unspecified',
'model.modal.gemini_backend.gemini_api': 'Gemini API',
'model.modal.gemini_backend.vertex_ai': 'Vertex AI',
'model.modal.gemini_project.label': 'Project',
'model.modal.gemini_project.placeholder': 'Optional, e.g., my-project',
'model.modal.gemini_location.label': 'Location',
'model.modal.gemini_location.placeholder': 'Optional, e.g., us-central1',
'model.modal.thinking_type.label': 'Thinking Type',
'model.modal.thinking_type.enable': 'Enable',
'model.modal.thinking_type.disable': 'Disable',
'model.modal.thinking_type.auto': 'Auto',
// Knowledge Config
'knowledge.error.load_failed_json': 'Load knowledge config failed: invalid JSON response',
'knowledge.error.load_failed_msg': 'Load knowledge config failed: {msg}',
'knowledge.error.load_failed_empty': 'Load knowledge config failed: empty response data',
'knowledge.error.load_failed': 'Load knowledge config failed',
'knowledge.error.load_failed_reason': 'Load knowledge config failed: {reason}',
'knowledge.log.local_preview_no_demo': 'Local preview: no demo data used',
'knowledge.section.embedding': 'Embedding Config',
'knowledge.connection': '{name} Connection',
'knowledge.section.rerank': 'Rerank Config',
'knowledge.section.ocr': 'OCR Config',
'knowledge.section.parser': 'Parser Config',
'knowledge.section.builtin_model': 'Knowledge Built-in Model Config',
'knowledge.label.choose_builtin_model': 'Choose Built-in Model',
'knowledge.placeholder.select_model': 'Please select a model',
'knowledge.loading.model_list': 'Loading model list...',
'knowledge.empty.model_list': 'No model list retrieved',
'knowledge.placeholder.model_fallback': 'Model #{id}',
'knowledge.tip.setup_model_first': 'Please configure models first',
'knowledge.error.model_list_load_failed': 'Failed to load model list',
'knowledge.error.render_failed': 'Render knowledge config failed',
'knowledge.error.save_failed': 'Failed to save config'
},
};
let currentLocale = 'zh-CN';
function t(key, vars) {
const dict = I18N[currentLocale] || I18N['zh-CN'];
let text = dict[key] || I18N['zh-CN'][key] || key;
if (vars && typeof vars === 'object') {
text = String(text).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? `{${k}}`));
}
return text;
}
function setLocale(locale) {
const normalized = (locale === 'en-US' || locale === 'zh-CN') ? locale : (locale && String(locale).toLowerCase().startsWith('en') ? 'en-US' : 'zh-CN');
currentLocale = normalized;
try { localStorage.setItem('locale', normalized); } catch(_) {}
document.documentElement.lang = normalized;
applyTranslations();
}
function setNavLinkText(link, text) {
if (!link) return;
const icon = link.querySelector('.nav-icon');
const arrow = link.querySelector('.nav-arrow');
if (icon || arrow) {
const iconHTML = icon ? icon.outerHTML + ' ' : '';
const arrowHTML = arrow ? ' ' + arrow.outerHTML : '';
link.innerHTML = iconHTML + (text || '') + arrowHTML;
} else {
link.textContent = text || '';
}
}
function applyTranslations() {
// 左侧导航
setNavLinkText(document.querySelector('[data-page="config"]'), t('nav.config'));
const basicLink = document.querySelector('[data-page="basic-config"]');
if (basicLink) basicLink.innerHTML = '<span class="submenu-icon">🔧</span>' + t('nav.basic');
const modelLink = document.querySelector('[data-page="model-management"]');
if (modelLink) modelLink.innerHTML = '<span class="submenu-icon">🤖</span>' + t('nav.model');
const knowledgeLink = document.querySelector('[data-page="knowledge-config"]');
if (knowledgeLink) knowledgeLink.innerHTML = '<span class="submenu-icon">📚</span>' + t('nav.knowledge');
// 顶部标题(根据当前 hash
const hash = window.location.hash.substring(1) || 'basic-config';
const map = {
'basic-config': { title: t('page.basic.title'), desc: t('page.basic.desc') },
'model-management': { title: t('page.model.title'), desc: t('page.model.desc') },
'knowledge-config': { title: t('page.knowledge.title'), desc: t('page.knowledge.desc') },
};
const meta = map[hash] || { title: t('page.default.title'), desc: t('page.default.desc') };
const pt = document.getElementById('pageTitle');
const pd = document.getElementById('pageDescription');
if (pt) pt.textContent = meta.title;
if (pd) pd.textContent = meta.desc;
// 侧边栏在线状态
const us = document.getElementById('userStatus');
if (us) us.textContent = t('status.online');
// 初始 loading 文案
const loadingSpan = document.querySelector('#pageContent .loading span');
if (loadingSpan) loadingSpan.textContent = t('loading');
}
// 页面导航功能
const navLinks = document.querySelectorAll('.nav-link');
const submenuLinks = document.querySelectorAll('.submenu-link');
const pageTitle = document.getElementById('pageTitle');
const pageDescription = document.getElementById('pageDescription');
const pageContent = document.getElementById('pageContent');
function handleApiCode(data) {
const code = Number(data?.code);
if (code === 700012006) {
window.location.href = '/sign?redirect=%2Fadmin';
return true;
}
if (code === 401) {
const msg = data?.msg || data?.message || t('error.generic');
alert(msg);
window.location.href = '/';
return true;
}
return false;
}
// 页面初始化
document.addEventListener('DOMContentLoaded', function() {
// 初始化语言(优先使用本地存储,否则使用浏览器语言)
const storedLocale = (() => { try { return localStorage.getItem('locale'); } catch(_) { return null; } })();
setLocale(storedLocale || (navigator.language && navigator.language.toLowerCase().startsWith('en') ? 'en-US' : 'zh-CN'));
// 加载用户信息
loadUserInfo();
// 初始化子菜单功能
initSubmenuToggle();
// 默认展开配置管理子菜单
const configMenu = document.querySelector('[data-page="config"]');
if (configMenu) {
const navItem = configMenu.closest('.nav-item');
if (navItem) {
navItem.classList.add('expanded');
}
}
// 从URL hash获取当前页面,如果没有则显示默认页面
initPageFromURL();
});
// 从URL初始化页面
function initPageFromURL() {
const hash = window.location.hash.substring(1); // 去掉#号
if (hash) {
// 如果URL中有hash,加载对应页面
loadPageFromHash(hash);
} else {
// 如果没有hash,显示默认页面
showDefaultPage();
}
}
// 从hash加载页面
function loadPageFromHash(hash) {
const pageConfig = {
'basic-config': { title: t('page.basic.title'), description: t('page.basic.desc') },
'model-management': { title: t('page.model.title'), description: t('page.model.desc') },
'knowledge-config': { title: t('page.knowledge.title'), description: t('page.knowledge.desc') }
};
if (pageConfig[hash]) {
// 设置对应的导航项为活动状态
const targetLink = document.querySelector(`[data-page="${hash}"]`);
if (targetLink) {
// 移除所有活动状态
navLinks.forEach(l => l.classList.remove('active'));
submenuLinks.forEach(l => l.classList.remove('active'));
// 添加当前活动状态
targetLink.classList.add('active');
// 如果是子菜单项,确保父菜单展开
if (targetLink.classList.contains('submenu-link')) {
const parentNavItem = targetLink.closest('.nav-item');
if (parentNavItem) {
parentNavItem.classList.add('expanded');
}
}
}
// 加载页面内容
loadPageContent(hash);
} else {
// 如果hash不存在,显示默认页面
showDefaultPage();
}
}
// 加载用户信息
async function loadUserInfo() {
try {
// 从当前页面URL获取host
const currentHost = window.location.origin;
const apiUrl = `${currentHost}/api/passport/account/info/v2/`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({})
});
const result = await response.json();
if (handleApiCode(result)) {
return;
}
if (result.code === 0 && result.data) {
// 应用语言
const apiLocale = result.data.locale;
const storedLocale = (() => { try { return localStorage.getItem('locale'); } catch(_) { return null; } })();
setLocale(apiLocale || storedLocale || (navigator.language && navigator.language.toLowerCase().startsWith('en') ? 'en-US' : 'zh-CN'));
// 更新用户名
const userNameElement = document.getElementById('userName');
if (userNameElement && result.data.name) {
userNameElement.textContent = result.data.name;
}
// 更新用户头像
const userAvatarElement = document.getElementById('userAvatar');
if (userAvatarElement && result.data.avatar_url) {
// 创建img元素替换原有的emoji
const img = document.createElement('img');
img.src = result.data.avatar_url;
img.alt = result.data.name || '用户头像';
img.style.width = '100%';
img.style.height = '100%';
img.style.borderRadius = '50%';
img.style.objectFit = 'cover';
// 添加错误处理,如果图片加载失败则显示默认图标
img.onerror = function() {
userAvatarElement.innerHTML = '👤';
};
userAvatarElement.innerHTML = '';
userAvatarElement.appendChild(img);
}
} else {
console.warn('用户信息接口返回异常:', result);
// 接口异常时也设置一次语言(兜底)
const storedLocale = (() => { try { return localStorage.getItem('locale'); } catch(_) { return null; } })();
setLocale(storedLocale || (navigator.language && navigator.language.toLowerCase().startsWith('en') ? 'en-US' : 'zh-CN'));
}
} catch (error) {
console.error('加载用户信息失败:', error);
// 保持默认显示
}
}
// 初始化子菜单切换功能
function initSubmenuToggle() {
const hasSubmenuItems = document.querySelectorAll('.has-submenu');
hasSubmenuItems.forEach(item => {
const navLink = item.querySelector('.nav-link');
navLink.addEventListener('click', (e) => {
e.preventDefault();
// 切换展开状态
item.classList.toggle('expanded');
});
});
}
// 导航点击事件
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
const parentItem = link.closest('.nav-item');
// 如果不是子菜单项,处理页面跳转
if (!parentItem.classList.contains('has-submenu')) {
e.preventDefault();
// 移除所有活动状态
navLinks.forEach(l => l.classList.remove('active'));
submenuLinks.forEach(l => l.classList.remove('active'));
// 添加当前活动状态
link.classList.add('active');
const pageName = link.getAttribute('data-page');
// 更新URL hash
window.location.hash = pageName;
loadPageContent(pageName);
}
});
});
// 子菜单点击事件
submenuLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
// 移除所有活动状态
navLinks.forEach(l => l.classList.remove('active'));
submenuLinks.forEach(l => l.classList.remove('active'));
// 添加当前活动状态
link.classList.add('active');
const pageName = link.getAttribute('data-page');
// 更新URL hash
window.location.hash = pageName;
loadPageContent(pageName);
});
});
// 加载页面内容
function loadPageContent(pageName) {
const pageConfig = {
'basic-config': { title: t('page.basic.title'), description: t('page.basic.desc') },
'model-management': { title: t('page.model.title'), description: t('page.model.desc') },
'knowledge-config': { title: t('page.knowledge.title'), description: t('page.knowledge.desc') }
};
const config = pageConfig[pageName] || { title: t('page.default.title'), description: t('page.default.desc') };
pageTitle.textContent = config.title;
pageDescription.textContent = config.description;
// 显示页面内容
showPageContent(pageName, config.title);
}
// 显示默认页面
function showDefaultPage() {
// 设置基础配置为选中状态
const basicConfigLink = document.querySelector('[data-page="basic-config"]');
if (basicConfigLink) {
// 移除所有活动状态
navLinks.forEach(l => l.classList.remove('active'));
submenuLinks.forEach(l => l.classList.remove('active'));
// 添加基础配置的活动状态
basicConfigLink.classList.add('active');
}
// 设置默认页面的URL hash
window.location.hash = 'basic-config';
loadPageContent('basic-config');
}
// 显示页面内容
function showPageContent(pageName, title) {
// 如果是基础配置页面,加载config.html的内容
if (pageName === 'basic-config') {
loadBasicConfigPage();
return;
}
// 如果是模型管理页面,加载模型列表
if (pageName === 'model-management') {
loadModelManagementPage();
return;
}
// 如果是知识库配置页面,加载知识库配置
if (pageName === 'knowledge-config') {
loadKnowledgeConfigPage();
return;
}
pageContent.innerHTML = `
<div style="padding: 30px;">
<div style="background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h2 style="color: #2c3e50; margin-bottom: 20px;">${title}</h2>
<p style="color: #7f8c8d; margin-bottom: 30px;">${t('page.contentArea', { title })}</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px;">
<div style="background: #f8f9fa; padding: 20px; border-radius: 6px; border-left: 4px solid #3498db;">
<h4 style="color: #2c3e50; margin: 0 0 10px;">${t('feature.module1')}</h4>
<p style="color: #7f8c8d; margin: 0; font-size: 14px;">${t('feature.desc')}</p>
</div>
<div style="background: #f8f9fa; padding: 20px; border-radius: 6px; border-left: 4px solid #e74c3c;">
<h4 style="color: #2c3e50; margin: 0 0 10px;">${t('feature.module2')}</h4>
<p style="color: #7f8c8d; margin: 0; font-size: 14px;">${t('feature.desc')}</p>
</div>
<div style="background: #f8f9fa; padding: 20px; border-radius: 6px; border-left: 4px solid #f39c12;">
<h4 style="color: #2c3e50; margin: 0 0 10px;">${t('feature.module3')}</h4>
<p style="color: #7f8c8d; margin: 0; font-size: 14px;">${t('feature.desc')}</p>
</div>
</div>
<div style="margin-top: 30px; padding: 20px; background: #ecf0f1; border-radius: 6px;">
<h4 style="color: #2c3e50; margin: 0 0 15px;">${t('quick.title')}</h4>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button style="background: #3498db; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px;">${t('quick.new')}</button>
<button style="background: #2ecc71; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px;">${t('quick.import')}</button>
<button style="background: #f39c12; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px;">${t('quick.export')}</button>
<button style="background: #95a5a6; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px;">${t('quick.settings')}</button>
</div>
</div>
</div>
</div>
`;
}
// 加载基础配置页面(已内联到 index.html
function loadBasicConfigPage() {
ensureConfigStylesInjected();
pageContent.innerHTML = getBasicConfigMarkup();
// 绑定弹窗函数到全局浮层
overrideConfigOverlay();
// 初始化交互与数据
setupConfigEventListeners();
initConfigEmailTags();
setTimeout(() => {
if (typeof window.loadConfig === 'function') {
window.loadConfig();
}
}, 0);
}
// 将配置页的弹窗绑定到 index.html 的全局浮层(幂等且无递归)
function overrideConfigOverlay() {
if (window.__overlayBound) return;
const baseShow = (typeof window.showOverlay === 'function') ? window.showOverlay.bind(window) : function(message, type){
// 兜底:如果原函数不存在,直接调用本地实现
const overlay = document.getElementById('overlay');
const overlayContent = document.getElementById('overlayContent');
const overlayMessage = document.getElementById('overlayMessage');
if (!overlay || !overlayContent || !overlayMessage) { alert(String(message || '未知错误')); return; }
overlayMessage.textContent = String(message || '');
overlayContent.className = `overlay-content ${type === 'success' ? 'success' : 'error'}`;
overlay.classList.add('show');
overlay.onclick = function(e){ if (e.target === overlay) hideOverlay(); };
setTimeout(() => hideOverlay(), 3000);
};
const baseHide = (typeof window.hideOverlay === 'function') ? window.hideOverlay.bind(window) : function(){
const overlay = document.getElementById('overlay');
if (overlay) overlay.classList.remove('show');
};
window.showOverlay = function(message, type) { baseShow(message, type); };
window.hideOverlay = function() { baseHide(); };
window.showError = function(message) { baseShow(message || t('error.generic'), 'error'); };
window.showSuccess = function(message) { baseShow(message || t('success.generic'), 'success'); };
window.__overlayBound = true;
}
// 注入基础配置页 CSS(移除 overlay 相关样式)
const CONFIG_PAGE_CSS = `
.page-content { max-width: 1000px; margin: 0 auto; padding: 30px; }
.page-header { background: white; border-radius: 12px; padding: 30px; margin-bottom: 30px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); border-left: 4px solid #3498db; }
.page-title { font-size: 28px; font-weight: 600; color: #2c3e50; margin-bottom: 8px; }
.page-subtitle { font-size: 16px; color: #6c757d; font-weight: 400; }
.config-section { background: white; border-radius: 12px; padding: 30px; margin-bottom: 24px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); border: 1px solid #e1e8ed; transition: all 0.3s ease; }
.config-section:hover { box-shadow: 0 4px 20px rgba(0,0,0,0.12); }
.section-title { font-size: 20px; font-weight: 600; color: #2c3e50; margin-bottom: 24px; display: flex; align-items: center; padding-bottom: 12px; border-bottom: 2px solid #f1f3f4; }
.section-icon { margin-right: 12px; font-size: 20px; color: #3498db; }
.form-group { margin-bottom: 24px; position: relative; }
.form-label { display: block; font-weight: 500; color: #374151; margin-bottom: 8px; font-size: 14px; }
.form-input { width: 100%; padding: 12px 16px; border: 2px solid #e1e8ed; border-radius: 8px; font-size: 14px; transition: all 0.3s ease; background: white; box-sizing: border-box; font-family: inherit; }
.form-input:focus { outline: none; border-color: #3498db; box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); }
.form-input:hover:not(:focus) { border-color: #bdc3c7; }
.form-textarea { min-height: 100px; resize: vertical; font-family: inherit; }
.form-select { appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m6 8 4 4 4-4'/%3e%3c/svg%3e"); background-position: right 12px center; background-repeat: no-repeat; background-size: 16px; padding-right: 40px; cursor: pointer; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.checkbox-group { display: flex; align-items: center; margin-bottom: 16px; padding: 12px 16px; background: #f8f9fa; border-radius: 8px; border: 2px solid #e1e8ed; transition: all 0.3s ease; }
.checkbox-group:hover { background: #f1f3f4; border-color: #bdc3c7; }
.checkbox-input { margin-right: 12px; width: 18px; height: 18px; accent-color: #3498db; cursor: pointer; }
.checkbox-group label { font-weight: 500; color: #374151; cursor: pointer; font-size: 14px; }
.radio-group { display: flex; gap: 16px; margin-top: 8px; }
.radio-item { display: flex; align-items: center; padding: 10px 16px; background: white; border-radius: 8px; border: 2px solid #e1e8ed; transition: all 0.3s ease; cursor: pointer; }
.radio-item:hover { background: #f8f9fa; border-color: #3498db; }
.radio-item:has(input:checked) { background: #e8f4fd; border-color: #3498db; box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); }
.radio-input { margin-right: 8px; width: 18px; height: 18px; accent-color: #3498db; cursor: pointer; }
.radio-item label { font-weight: 500; color: #374151; cursor: pointer; font-size: 14px; }
.btn { padding: 12px 24px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; margin-right: 12px; display: inline-flex; align-items: center; gap: 6px; text-decoration: none; font-family: inherit; }
.btn-primary { background: #3498db; color: white; box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3); }
.btn-primary:hover { background: #2980b9; box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4); }
.btn-primary:active { transform: translateY(1px); box-shadow: 0 1px 4px rgba(52, 152, 219, 0.3); }
.btn-secondary { background: #f8f9fa; color: #6c757d; border: 2px solid #e1e8ed; }
.btn-secondary:hover { background: #e9ecef; border-color: #bdc3c7; color: #495057; }
.registration-config-group { background: #f8f9fa; border-radius: 12px; padding: 20px; border: 2px solid #e1e8ed; margin-bottom: 20px; position: relative; }
.registration-config-group::before { content: '👥'; position: absolute; top: -12px; left: 20px; background: white; padding: 0 8px; font-size: 16px; color: #3498db; }
.registration-config-group .checkbox-group { margin-bottom: 16px; background: white; border: 2px solid #3498db; box-shadow: 0 2px 8px rgba(52, 152, 219, 0.1); }
.plugin-config-group::before { content: '🧩'; position: absolute; top: -12px; left: 20px; background: white; padding: 0 8px; font-size: 16px; color: #3498db; }
.whitelist-email-config { margin-left: 30px; padding-left: 20px; border-left: 3px solid #3498db; background: white; border-radius: 8px; padding: 16px 20px; margin-bottom: 0; transition: all 0.3s ease; }
.whitelist-email-config.show { animation: slideDown 0.3s ease-out; }
.email-tags-container { border: 2px solid #e1e8ed; border-radius: 8px; padding: 8px; background: white; min-height: 48px; display: flex; flex-wrap: wrap; align-items: flex-start; gap: 6px; cursor: text; transition: border-color 0.3s ease; }
.email-tags-container:focus-within { border-color: #3498db; box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); }
.email-tags { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.email-tag { background: #3498db; color: white; padding: 4px 8px; border-radius: 16px; font-size: 12px; display: inline-flex; align-items: center; gap: 4px; max-width: 200px; overflow: hidden; }
.email-tag-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.email-tag-remove { background: none; border: none; color: white; cursor: pointer; padding: 0; width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; line-height: 1; transition: background-color 0.2s ease; }
.email-tag-remove:hover { background: rgba(255, 255, 255, 0.2); }
.email-input { border: none !important; box-shadow: none !important; padding: 4px 0 !important; margin: 0 !important; flex: 1; min-width: 200px; background: transparent !important; }
.email-input:focus { outline: none !important; border: none !important; box-shadow: none !important; }
.help-text { font-size: 12px; color: #9ca3af; margin-top: 6px; font-style: italic; }
.sandbox-config { display: none; margin-top: 20px; padding: 20px; background: #f8f9fa; border-radius: 8px; border: 2px solid #e1e8ed; position: relative; }
.sandbox-config::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: #3498db; border-radius: 8px 8px 0 0; }
.sandbox-config.show { display: block; animation: slideDown 0.3s ease-out; }
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
.sandbox-config h4 { margin-bottom: 16px; color: #2c3e50; font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.sandbox-config h4::before { content: '🔧'; font-size: 16px; }
.loading { text-align: center; padding: 40px; color: #6c757d; background: white; border-radius: 12px; margin: 20px 0; box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
.loading::before { content: '⚙️'; font-size: 32px; display: block; margin-bottom: 12px; animation: spin 2s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.error { background: #fee2e2; color: #dc2626; padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; border-left: 4px solid #dc2626; font-weight: 500; }
.success { background: #dcfce7; color: #16a34a; padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; border-left: 4px solid #16a34a; font-weight: 500; }
.action-buttons { margin-top: 30px; padding-top: 24px; border-top: 2px solid #f1f3f4; text-align: center; }
`;
function ensureConfigStylesInjected() {
const existingStyle = document.querySelector('style[data-config-page="true"]');
if (existingStyle) return;
const style = document.createElement('style');
style.setAttribute('data-config-page', 'true');
style.textContent = CONFIG_PAGE_CSS;
document.head.appendChild(style);
}
// 基础配置页静态 DOM
function getBasicConfigMarkup() {
return `
<div class="page-content">
<div id="loadingMessage" class="loading" style="display:none;">${t('basic.loading')}</div>
<div id="errorMessage" class="error" style="display:none;"></div>
<div id="successMessage" class="success" style="display:none;"></div>
<form id="configForm" style="display:none;">
<div class="config-section">
<div class="form-group">
<label class="form-label">${t('basic.label.admin_emails')}</label>
<div class="email-tags-container" id="emailTagsContainer">
<div class="email-tags" id="emailTags"></div>
<input type="text" id="adminEmails" class="form-input email-input" placeholder="${t('basic.placeholder.email_input')}" />
</div>
</div>
<div class="registration-config-group plugin-config-group">
<div class="form-group">
<label class="form-label">Coze API Token</label>
<input type="password" id="cozeApiToken" class="form-input" placeholder="${t('basic.placeholder.coze_api_token')}">
<div style="margin-top:8px;font-size:12px;">
<a href="https://www.coze.cn/open/oauth/pats" target="_blank" rel="noopener noreferrer" style="color:#2980b9;text-decoration:none;">${t('basic.help.coze_api_token_apply')}</a>
</div>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="cozeSaasPluginEnabled" class="checkbox-input" />
<label for="cozeSaasPluginEnabled">${t('basic.label.enable_coze_saas')}</label>
</div>
</div>
<div class="form-group">
<label class="form-label">Coze SaaS API Base URL</label>
<input type="text" id="cozeSaasApiBaseUrl" class="form-input" placeholder="https://api.coze.cn" />
</div>
</div>
<div class="registration-config-group">
<div class="checkbox-group">
<input type="checkbox" id="disableUserRegistration" class="checkbox-input" />
<label for="disableUserRegistration">${t('basic.label.disable_user_registration')}</label>
</div>
<div id="whitelistEmailGroup" class="form-group whitelist-email-config" style="display:none;">
<label class="form-label">${t('basic.label.whitelist_emails')}</label>
<div class="email-tags-container" id="whitelistEmailTagsContainer">
<div class="email-tags" id="whitelistEmailTags"></div>
<input type="text" id="allowRegistrationEmail" class="form-input email-input" placeholder="${t('basic.placeholder.email_input')}" />
</div>
<div class="help-text">${t('basic.help.whitelist')}</div>
</div>
</div>
<div class="form-group">
<label class="form-label">${t('basic.label.python_env')}</label>
<div class="radio-group">
<div class="radio-item"><input type="radio" id="codeRunnerLocal" name="codeRunnerType" value="0" class="radio-input" /><label for="codeRunnerLocal">Local</label></div>
<div class="radio-item"><input type="radio" id="codeRunnerSandbox" name="codeRunnerType" value="1" class="radio-input" /><label for="codeRunnerSandbox">Sandbox</label></div>
</div>
<div class="help-text">${t('basic.help.local_risk')}</div>
</div>
<div id="sandboxConfig" class="sandbox-config">
<h4 style="margin-bottom: 15px; color: #2c3e50;">${t('basic.sandbox.title')}</h4>
<div class="form-group">
<label class="form-label">${t('basic.label.allow_env')}</label>
<input type="text" id="allowEnv" class="form-input" placeholder="PATH,USERNAME">
</div>
<div class="form-group">
<label class="form-label">${t('basic.label.allow_read')}</label>
<input type="text" id="allowRead" class="form-input" placeholder="/tmp,./data">
</div>
<div class="form-group">
<label class="form-label">${t('basic.label.allow_write')}</label>
<input type="text" id="allowWrite" class="form-input" placeholder="/tmp,./data">
</div>
<div class="form-group">
<label class="form-label">${t('basic.label.allow_run')}</label>
<input type="text" id="allowRun" class="form-input" placeholder="python,git">
</div>
<div class="form-group">
<label class="form-label">${t('basic.label.allow_net')}</label>
<input type="text" id="allowNet" class="form-input" placeholder="cdn.jsdelivr.net">
</div>
<div class="form-group">
<label class="form-label">${t('basic.label.allow_ffi')}</label>
<input type="text" id="allowFfi" class="form-input" placeholder="/usr/lib/libm.so">
</div>
<div class="form-group">
<label class="form-label">${t('basic.label.node_modules_dir')}</label>
<input type="text" id="nodeModulesDir" class="form-input" placeholder="/tmp/path/node_modules">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">${t('basic.label.timeout_seconds')}</label>
<input type="number" id="timeoutSeconds" class="form-input" placeholder="60" min="1" max="3600">
</div>
<div class="form-group">
<label class="form-label">${t('basic.label.memory_limit_mb')}</label>
<input type="number" id="memoryLimitMb" class="form-input" placeholder="100" min="1" max="10240">
</div>
</div>
</div>
</div>
<div class="config-section">
<h3 class="section-title"><span class="section-icon">🌐</span>${t('server.title')}</h3>
<div class="form-group">
<label class="form-label">${t('server.host.label')}</label>
<input type="text" id="serverHost" class="form-input" placeholder="localhost:8888" />
<div class="help-text">${t('server.host.help')}</div>
</div>
</div>
<div class="action-buttons">
<button type="button" class="btn btn-primary" onclick="saveConfig()">💾 ${t('btn.save')}</button>
<button type="button" class="btn btn-secondary" onclick="loadConfig()">🔄 ${t('btn.reload')}</button>
</div>
</form>
</div>`;
}
// -------------------- 基础配置页脚本(避免与全局命名冲突) --------------------
let currentConfig = {};
let emailTags = [];
let whitelistEmailTags = [];
function getConfigTagsArray(containerId) {
if (containerId === 'emailTags') return emailTags;
if (containerId === 'whitelistEmailTags') return whitelistEmailTags;
return [];
}
function initConfigEmailTags() {
initConfigEmailTagsForInput('adminEmails', 'emailTags', 'emailTags');
initConfigEmailTagsForInput('allowRegistrationEmail', 'whitelistEmailTags', 'whitelistEmailTags');
}
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function initConfigEmailTagsForInput(inputId, containerId, type) {
const inputEl = document.getElementById(inputId);
const container = document.getElementById(containerId);
if (!inputEl || !container) return;
const handleAdd = () => {
const value = inputEl.value.trim();
if (value) {
addConfigEmailTagForInput(type, value);
inputEl.value = '';
}
};
inputEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
handleAdd();
} else if (e.key === 'Backspace' && !inputEl.value) {
const tags = getConfigTagsArray(containerId);
tags.pop();
renderConfigEmailTagsForInput(containerId, tags);
}
});
inputEl.addEventListener('blur', handleAdd);
}
function addConfigEmailTagForInput(type, value) {
const containerId = type === 'emailTags' ? 'emailTags' : 'whitelistEmailTags';
const tags = getConfigTagsArray(containerId);
if (!isValidEmail(value)) {
showError('请输入有效的邮箱地址');
return;
}
if (tags.includes(value)) {
showError('该邮箱已存在');
return;
}
tags.push(value);
renderConfigEmailTagsForInput(containerId, tags);
}
function removeConfigEmailTagForInput(type, index) {
const containerId = type === 'emailTags' ? 'emailTags' : 'whitelistEmailTags';
const tags = getConfigTagsArray(containerId);
tags.splice(index, 1);
renderConfigEmailTagsForInput(containerId, tags);
}
function renderConfigEmailTagsForInput(containerId, tags) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
tags.forEach((tag, index) => {
const tagElement = document.createElement('span');
tagElement.className = 'email-tag';
tagElement.innerHTML = `
<span class="email-tag-text" title="${tag}">${tag}</span>
<button class="email-tag-remove" onclick="removeConfigEmailTagForInput('${containerId === 'emailTags' ? 'emailTags' : 'whitelistEmailTags'}', ${index})">×</button>
`;
container.appendChild(tagElement);
});
}
function setEmailTags(emails) {
const inputEmails = Array.isArray(emails) ? emails : (emails || '').split(/[,\s]+/).filter(Boolean);
const nowEmails = emailTags || [];
const allEmails = Array.from(new Set([...(nowEmails || []), ...(inputEmails || [])]));
emailTags = allEmails;
renderConfigEmailTagsForInput('emailTags', emailTags);
}
function getEmailTags() {
return (emailTags || []).join(',');
}
function setWhitelistEmailTags(emails) {
const inputEmails = Array.isArray(emails) ? emails : (emails || '').split(/[,\s]+/).filter(Boolean);
const nowEmails = whitelistEmailTags || [];
const allEmails = Array.from(new Set([...(nowEmails || []), ...(inputEmails || [])]));
whitelistEmailTags = allEmails;
renderConfigEmailTagsForInput('whitelistEmailTags', whitelistEmailTags);
}
function getWhitelistEmailTags() {
return (whitelistEmailTags || []).join(',');
}
function setupConfigEventListeners() {
const disableEl = document.getElementById('disableUserRegistration');
const whitelistGroup = document.getElementById('whitelistEmailGroup');
if (disableEl && whitelistGroup) {
disableEl.addEventListener('change', function () {
toggleConfigWhitelistEmailConfig(disableEl.checked);
});
}
const localEl = document.getElementById('codeRunnerLocal');
const sandboxEl = document.getElementById('codeRunnerSandbox');
if (localEl && sandboxEl) {
const updateSandbox = () => toggleConfigSandboxConfig(sandboxEl.checked);
localEl.addEventListener('change', updateSandbox);
sandboxEl.addEventListener('change', updateSandbox);
}
}
function toggleConfigWhitelistEmailConfig(show) {
const el = document.getElementById('whitelistEmailGroup');
if (!el) return;
el.style.display = show ? 'block' : 'none';
if (show) el.classList.add('show'); else el.classList.remove('show');
}
function toggleConfigSandboxConfig(show) {
const el = document.getElementById('sandboxConfig');
if (!el) return;
el.style.display = show ? 'block' : 'none';
if (show) el.classList.add('show'); else el.classList.remove('show');
}
function loadConfig() {
showConfigLoading(true);
hideMessages();
fetch('/api/admin/config/basic/get')
.then(response => response.json())
.then(data => {
if (handleApiCode(data)) {
showConfigLoading(false);
return;
}
if (!data || !data.configuration) {
showError(t('error.load_failed_empty'));
// 在无后端或接口异常时也展示表单,便于手动配置
populateForm({});
showConfigForm(true);
showConfigLoading(false);
return;
}
currentConfig = data.configuration || {};
populateForm(currentConfig);
showConfigForm(true);
showConfigLoading(false);
})
.catch(err => {
console.error('Failed to load configuration:', err);
showError(t('error.load_failed_json'));
// 无后端时展示空表单以便继续操作
populateForm({});
showConfigForm(true);
showConfigLoading(false);
});
}
function populateForm(cfg) {
document.getElementById('serverHost').value = cfg.server_host || '';
// 管理员邮箱
setEmailTags(cfg.admin_emails || '');
// 插件配置:Coze API Token + SaaS 相关
const pluginCfg = cfg.plugin_configuration || {};
const cozeEl = document.getElementById('cozeApiToken');
if (cozeEl) cozeEl.value = pluginCfg.coze_api_token || cfg.coze_api_token || '';
const saasEnabledEl = document.getElementById('cozeSaasPluginEnabled');
if (saasEnabledEl) saasEnabledEl.checked = (pluginCfg.coze_saas_plugin_enabled === true || pluginCfg.coze_saas_plugin_enabled === 'true');
const saasBaseUrlEl = document.getElementById('cozeSaasApiBaseUrl');
if (saasBaseUrlEl) saasBaseUrlEl.value = pluginCfg.coze_saas_api_base_url || 'https://api.coze.cn';
// 禁止用户注册与白名单
const disableReg = cfg.disable_user_registration === true || cfg.disable_user_registration === 'true';
const disableEl = document.getElementById('disableUserRegistration');
if (disableEl) disableEl.checked = disableReg;
toggleConfigWhitelistEmailConfig(disableReg);
setWhitelistEmailTags(cfg.allow_registration_email || '');
// 代码运行环境
const crt = Number(cfg.code_runner_type || 0);
const localEl = document.getElementById('codeRunnerLocal');
const sandboxEl = document.getElementById('codeRunnerSandbox');
if (localEl && sandboxEl) {
localEl.checked = crt !== 1;
sandboxEl.checked = crt === 1;
}
toggleConfigSandboxConfig(crt === 1);
// Sandbox 配置
const s = cfg.sandbox_config || {};
if (document.getElementById('allowEnv')) document.getElementById('allowEnv').value = s.allow_env || '';
if (document.getElementById('allowRead')) document.getElementById('allowRead').value = s.allow_read || '';
if (document.getElementById('allowWrite')) document.getElementById('allowWrite').value = s.allow_write || '';
if (document.getElementById('allowRun')) document.getElementById('allowRun').value = s.allow_run || '';
if (document.getElementById('allowNet')) document.getElementById('allowNet').value = s.allow_net || 'cdn.jsdelivr.net';
if (document.getElementById('allowFfi')) document.getElementById('allowFfi').value = s.allow_ffi || '';
if (document.getElementById('nodeModulesDir')) document.getElementById('nodeModulesDir').value = s.node_modules_dir || '';
if (document.getElementById('timeoutSeconds')) document.getElementById('timeoutSeconds').value = s.timeout_seconds || '';
if (document.getElementById('memoryLimitMb')) document.getElementById('memoryLimitMb').value = s.memory_limit_mb || '';
}
function collectFormData() {
const serverHost = document.getElementById('serverHost').value.trim();
const adminEmails = getEmailTags();
const allowedEmails = getWhitelistEmailTags();
const disableReg = document.getElementById('disableUserRegistration').checked;
const codeRunnerType = document.getElementById('codeRunnerSandbox').checked ? 1 : 0;
const cozeApiToken = (document.getElementById('cozeApiToken')?.value || '').trim();
const cozeSaasPluginEnabled = !!(document.getElementById('cozeSaasPluginEnabled')?.checked);
const cozeSaasApiBaseUrlRaw = (document.getElementById('cozeSaasApiBaseUrl')?.value || '');
const cozeSaasApiBaseUrl = (cozeSaasApiBaseUrlRaw ? cozeSaasApiBaseUrlRaw : 'https://api.coze.cn').replace(/`/g, '').trim();
const sAllowEnv = (document.getElementById('allowEnv')?.value || '').trim();
const sAllowRead = (document.getElementById('allowRead')?.value || '').trim();
const sAllowWrite = (document.getElementById('allowWrite')?.value || '').trim();
const sAllowRun = (document.getElementById('allowRun')?.value || '').trim();
const sAllowNet = (document.getElementById('allowNet')?.value || '').trim();
const sAllowFfi = (document.getElementById('allowFfi')?.value || '').trim();
const sNodeModulesDir = (document.getElementById('nodeModulesDir')?.value || '').trim();
const sTimeoutSeconds = document.getElementById('timeoutSeconds')?.value;
const sMemoryLimitMb = document.getElementById('memoryLimitMb')?.value;
const configuration = {
admin_emails: adminEmails,
disable_user_registration: disableReg,
allow_registration_email: allowedEmails,
code_runner_type: codeRunnerType,
server_host: serverHost,
plugin_configuration: {
coze_saas_plugin_enabled: cozeSaasPluginEnabled,
coze_api_token: cozeApiToken,
coze_saas_api_base_url: cozeSaasApiBaseUrl
}
};
if (codeRunnerType === 1) {
configuration.sandbox_config = {
allow_env: sAllowEnv,
allow_read: sAllowRead,
allow_write: sAllowWrite,
allow_run: sAllowRun,
allow_net: sAllowNet || 'cdn.jsdelivr.net',
allow_ffi: sAllowFfi,
node_modules_dir: sNodeModulesDir,
timeout_seconds: sTimeoutSeconds ? parseFloat(sTimeoutSeconds) : undefined,
memory_limit_mb: sMemoryLimitMb ? parseInt(sMemoryLimitMb, 10) : undefined
};
}
return { configuration };
}
function saveConfig() {
const payload = collectFormData();
if (!payload.configuration.admin_emails) {
showError(t('error.admin_email_required'));
return;
}
if (!payload.configuration.server_host) {
showError(t('error.server_host_required'));
return;
}
// 验证当前标签中的邮箱格式(最终兜底检查)
const invalidAdmin = (emailTags || []).filter(e => e && !isValidEmail(e));
const invalidWhitelist = (whitelistEmailTags || []).filter(e => e && !isValidEmail(e));
if (invalidAdmin.length > 0) {
showError(t('error.invalid_admin_emails', { list: invalidAdmin.join(', ') }));
return;
}
if (invalidWhitelist.length > 0) {
showError(t('error.invalid_whitelist_emails', { list: invalidWhitelist.join(', ') }));
return;
}
showConfigLoading(true);
hideMessages();
fetch('/api/admin/config/basic/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (handleApiCode(data)) {
showConfigLoading(false);
return;
}
if (data && Number(data.code) === 0) {
showSuccess(t('success.saved'));
} else {
const msg = (data && (data.msg || data.message)) ? (data.msg || data.message) : t('error.save_failed');
showError(msg);
}
showConfigLoading(false);
})
.catch(err => {
console.error(t('knowledge.error.save_failed') + ':', err);
showError(t('error.save_failed_json'));
showConfigLoading(false);
});
}
function showConfigLoading(show) {
const el = document.getElementById('loadingMessage');
if (el) el.style.display = show ? 'block' : 'none';
}
function showConfigForm(show) {
const el = document.getElementById('configForm');
if (el) el.style.display = show ? 'block' : 'none';
}
function hideMessages() {
const errEl = document.getElementById('errorMessage');
const sucEl = document.getElementById('successMessage');
if (errEl) errEl.style.display = 'none';
if (sucEl) sucEl.style.display = 'none';
}
// 显示工作台内容
function showWorkspaceContent() {
const now = new Date();
const localeFmt = (currentLocale === 'en-US') ? 'en-US' : 'zh-CN';
const today = now.toLocaleDateString(localeFmt, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
});
const hour = now.getHours();
let greetingKey = 'workspace.greeting.evening';
if (hour < 6) {
greetingKey = 'workspace.greeting.late';
} else if (hour < 12) {
greetingKey = 'workspace.greeting.morning';
} else if (hour < 14) {
greetingKey = 'workspace.greeting.noon';
} else if (hour < 18) {
greetingKey = 'workspace.greeting.afternoon';
} else {
greetingKey = 'workspace.greeting.evening';
}
const greeting = t(greetingKey);
pageContent.innerHTML = `
<div style="padding: 30px;">
<div style="text-align: center; margin-bottom: 40px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px; border-radius: 15px; box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);">
<h1 style="margin: 0 0 15px; font-size: 2.5em; font-weight: 300;">${t('workspace.hero.title')}</h1>
<p style="margin: 0 0 20px; font-size: 1.2em; opacity: 0.9;">${greeting}</p>
<div style="background: rgba(255,255,255,0.2); padding: 15px; border-radius: 10px; display: inline-block;">
<p style="margin: 0; font-size: 1.1em; font-weight: 500;">📅 ${today}</p>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 25px;">
<div style="background: white; padding: 25px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); border-left: 5px solid #3498db;">
<div style="display: flex; align-items: center; margin-bottom: 15px;">
<div style="background: #3498db; color: white; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 15px; font-size: 18px;">🔧</div>
<h3 style="margin: 0; color: #2c3e50;">${t('workspace.card.basic.title')}</h3>
</div>
<p style="color: #7f8c8d; margin-bottom: 15px; line-height: 1.6;">${t('workspace.card.basic.desc')}</p>
<button onclick="loadPageContent('basic-config')" style="background: #3498db; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.3s;">${t('workspace.card.basic.cta')}</button>
</div>
<div style="background: white; padding: 25px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); border-left: 5px solid #2ecc71;">
<div style="display: flex; align-items: center; margin-bottom: 15px;">
<div style="background: #2ecc71; color: white; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 15px; font-size: 18px;">🤖</div>
<h3 style="margin: 0; color: #2c3e50;">${t('workspace.card.model.title')}</h3>
</div>
<p style="color: #7f8c8d; margin-bottom: 15px; line-height: 1.6;">${t('workspace.card.model.desc')}</p>
<button onclick="loadPageContent('model-management')" style="background: #2ecc71; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.3s;">${t('workspace.card.model.cta')}</button>
</div>
<div style="background: white; padding: 25px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); border-left: 5px solid #f39c12;">
<div style="display: flex; align-items: center; margin-bottom: 15px;">
<div style="background: #f39c12; color: white; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 15px; font-size: 18px;">📚</div>
<h3 style="margin: 0; color: #2c3e50;">${t('workspace.card.knowledge.title')}</h3>
</div>
<p style="color: #7f8c8d; margin-bottom: 15px; line-height: 1.6;">${t('workspace.card.knowledge.desc')}</p>
<button onclick="loadPageContent('knowledge-config')" style="background: #f39c12; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.3s;">${t('workspace.card.knowledge.cta')}</button>
</div>
</div>
<div style="margin-top: 30px; text-align: center; color: #95a5a6; font-size: 14px;">
<p>🚀 ${t('workspace.footer.cta')}</p>
</div>
</div>
`;
}
// 模型管理页面:加载与渲染
async function loadModelManagementPage() {
try {
showLoading();
const currentHost = window.location.origin;
const apiUrl = `${currentHost}/api/admin/config/model/list`;
const response = await fetch(apiUrl, { method: 'GET', headers: { 'Accept': 'application/json' } });
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (handleApiCode(data)) {
return;
}
renderModelManagement(data);
} catch (error) {
console.error(t('model.error.load_list_failed'), error);
showError(t('model.error.load_list_failed') + ': ' + error.message);
}
}
function sanitizeUrl(url) {
if (!url) return '';
return String(url).replace(/`/g, '').trim();
}
function maskApiKey(key) {
if (!key) return t('common.not_configured');
const s = String(key);
if (s.length <= 10) return '***';
return s.slice(0, 6) + '***' + s.slice(-4);
}
function getThinkingTypeText(thinkingTypeValue) {
const thinkingType = Number(thinkingTypeValue);
switch (thinkingType) {
case 0: return t('model.thinking_type.default');
case 1: return t('model.thinking_type.enable');
case 2: return t('model.thinking_type.disable');
case 3: return t('model.thinking_type.auto');
default: return '-';
}
}
function renderModelManagement(payload) {
try {
const providers = Array.isArray(payload?.provider_model_list) ? payload.provider_model_list : [];
const html = `
<div style="padding: 30px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div>
<h2 style="color:#2c3e50;margin:0 0 8px;">${t('model.list.title')}</h2>
</div>
<div style="font-size:12px;color:#95a5a6;">${t('model.provider.count', { count: providers.length })}</div>
</div>
<!-- 添加模型弹窗 -->
<div id="addModelModal" style="position:fixed;inset:0;background:rgba(0,0,0,0.35);display:none;align-items:center;justify-content:center;z-index:9999;">
<div style="background:#fff;width:480px;max-width:90vw;border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,0.2);overflow:hidden;">
<div style="padding:16px 20px;border-bottom:1px solid #f1f3f4;display:flex;justify-content:space-between;align-items:center;">
<div style="font-weight:600;color:#2c3e50;">${t('model.modal.add.title')}</div>
<button onclick="closeAddModelModal()" style="background:transparent;border:none;font-size:20px;color:#6c757d;cursor:pointer;">×</button>
</div>
<div style="padding:20px;display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<img id="modalProviderIcon" src="" alt="" style="width:24px;height:24px;border-radius:6px;object-fit:cover;display:none;">
<div id="modalProviderName" style="font-size:12px;color:#6c757d;"></div>
</div>
<label style="font-size:12px;color:#374151;">${t('model.modal.display_name.label')}<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></label>
<input id="newModelDisplayName" type="text" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;" placeholder="${t('model.modal.display_name.placeholder')}">
<label style="font-size:12px;color:#374151;">${t('model.modal.model.label')}<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></label>
<input id="newModelModel" type="text" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;" placeholder="${t('model.modal.model.placeholder')}">
<label id="newModelApiKeyLabel" style="font-size:12px;color:#374151;">${t('model.modal.api_key.label')}<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></label>
<input id="newModelApiKey" type="text" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;" placeholder="${t('model.modal.api_key.placeholder')}">
<label style="font-size:12px;color:#374151;">${t('model.modal.base_url.label')}</label>
<input id="newModelBaseUrl" type="text" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;" placeholder="${t('model.modal.base_url.placeholder')}">
<label style="font-size:12px;color:#374151;">${t('model.modal.enable_base64_url.label')}</label>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<input id="newModelEnableBase64Url" type="checkbox">
<span style="font-size:12px;color:#6c757d;">${t('model.modal.enable_base64_url.help')}</span>
</div>
<label id="newModelArkRegionLabel" style="font-size:12px;color:#374151;display:none;">${t('model.modal.ark_region.label')}</label>
<input id="newModelArkRegion" type="text" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;display:none;" placeholder="${t('model.modal.ark_region.placeholder')}">
<label id="newModelOpenAIByAzureLabel" style="font-size:12px;color:#374151;display:none;">${t('model.modal.azure.label')}</label>
<div id="newModelOpenAIByAzureWrapper" style="display:none;align-items:center;gap:8px;margin-bottom:4px;">
<input id="newModelOpenAIByAzure" type="checkbox">
<span style="font-size:12px;color:#6c757d;">${t('model.modal.azure.help')}</span>
</div>
<label id="newModelOpenAIApiVersionLabel" style="font-size:12px;color:#374151;display:none;">${t('model.modal.openai_api_version.label')}</label>
<input id="newModelOpenAIApiVersion" type="text" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;display:none;" placeholder="${t('model.modal.openai_api_version.placeholder')}">
<label id="newModelGeminiBackendLabel" style="font-size:12px;color:#374151;display:none;">${t('model.modal.gemini_backend.label')}</label>
<select id="newModelGeminiBackend" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;display:none;">
<option value="0" selected>${t('model.modal.gemini_backend.unspecified')}</option>
<option value="1">${t('model.modal.gemini_backend.gemini_api')}</option>
<option value="2">${t('model.modal.gemini_backend.vertex_ai')}</option>
</select>
<label id="newModelGeminiProjectLabel" style="font-size:12px;color:#374151;display:none;">${t('model.modal.gemini_project.label')}</label>
<input id="newModelGeminiProject" type="text" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;display:none;" placeholder="${t('model.modal.gemini_project.placeholder')}">
<label id="newModelGeminiLocationLabel" style="font-size:12px;color:#374151;display:none;">${t('model.modal.gemini_location.label')}</label>
<input id="newModelGeminiLocation" type="text" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;display:none;" placeholder="${t('model.modal.gemini_location.placeholder')}">
<label id="newModelThinkingTypeLabel" style="font-size:12px;color:#374151;display:none;">${t('model.modal.thinking_type.label')}</label>
<select id="newModelThinkingType" style="padding:8px;border:1px solid #e1e8ed;border-radius:8px;width:100%;display:none;">
<option value="2" selected>${t('model.modal.thinking_type.disable')}</option>
<option value="1">${t('model.modal.thinking_type.enable')}</option>
<option id="thinkingTypeAutoOption" value="3" style="display:none;">${t('model.modal.thinking_type.auto')}</option>
</select>
</div>
<div style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid #f1f3f4;">
<button onclick="closeAddModelModal()" style="background:#ecf0f1;color:#2c3e50;border:none;padding:8px 12px;border-radius:6px;cursor:pointer;">${t('common.cancel')}</button>
<button id="addModelSubmitBtn" onclick="saveNewModel()" style="background:#667eea;color:#fff;border:none;padding:8px 12px;border-radius:6px;cursor:pointer;">${t('common.save')}</button>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:20px;">
${providers.map(pm => {
const p = pm.provider || {};
const name = p.name?.zh_cn || p.name?.en_us || t('model.provider.unnamed');
const desc = p.description?.zh_cn || p.description?.en_us || '';
const icon = p.icon_url || p.icon_uri || '';
const models = Array.isArray(pm.model_list) ? pm.model_list : [];
return `
<div style="background:white;border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.08);border:1px solid #e1e8ed;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px;border-bottom:1px solid #f1f3f4;background:#f8f9fa;">
<div style="display:flex;align-items:center;gap:12px;">
<img src="${icon}" alt="${name}" onerror="this.style.display='none'" style="width:36px;height:36px;border-radius:8px;object-fit:cover;">
<div>
<div style="font-weight:600;color:#2c3e50;">${name}</div>
<div style="color:#6c757d;font-size:12px;">${desc}</div>
</div>
</div>
<button onclick="openAddModelModalWithProvider(this)" data-provider-name="${name}" data-provider-icon="${icon}" data-provider-class="${p.model_class}" style="background:#3498db;color:#fff;border:none;padding:8px 12px;border-radius:6px;font-size:12px;cursor:pointer;">${t('model.actions.add')}</button>
</div>
<div style="padding:16px;">
<div style="color:#7f8c8d;font-size:12px;margin-bottom:12px;">${t('model.provider.model_count', { count: models.length })}</div>
${models.map(m => {
const di = m.display_info || {};
const conn = m.connection?.base_conn_info || {};
const modelName = di.name || conn.model || t('model.unnamed');
const baseUrl = sanitizeUrl(conn.base_url || '');
const apiKeyMasked = maskApiKey(conn.api_key || '');
const outputTokens = di.output_tokens ?? '-';
const maxTokens = di.max_tokens ?? '-';
const statusText = m.status === 1 ? t('common.enabled') : t('common.disabled');
const statusColor = m.status === 1 ? '#2ecc71' : '#e74c3c';
const modelId = m.id ?? m.model_id ?? '';
return `
<div style="border:1px solid #e1e8ed;border-radius:10px;padding:12px;margin-bottom:12px;background:#ffffff;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="font-weight:500;color:#2c3e50;">${modelName}</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:${statusColor};">${statusText}</span>
<button onclick="deleteModel('${modelId}')" style="background:#e74c3c;color:#fff;border:none;padding:4px 8px;border-radius:6px;font-size:12px;cursor:pointer;">${t('common.delete')}</button>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;margin-top:10px;font-size:12px;color:#374151;">
<div>${t('model.field.id')}${m.id ?? m.model_id ?? '-'}</div>
<div>${t('model.field.model')}${conn.model || '-'}</div>
${p?.model_class === 20 ? '' : `<div>${t('model.field.api_key')}${apiKeyMasked}</div>`}
<div>${t('model.field.base64_url')}${m.enable_base64_url ? t('common.on') : t('common.off')}</div>
${[2, 3, 11, 15, 20].includes(p?.model_class) ? `<div>${t('model.field.thinking_type')}${getThinkingTypeText(conn.thinking_type ?? 0)}</div>` : ''}
${p?.model_class === 2 ? `<div>${t('model.field.region')}${m?.connection?.ark?.region || ''}</div>` : ''}
${p?.model_class === 11 ? `<div>${t('model.field.backend')}${m?.connection?.gemini?.backend ?? ''}</div><div>${t('model.field.project')}${m?.connection?.gemini?.project || ''}</div><div>${t('model.field.location')}${m?.connection?.gemini?.location || ''}</div>` : ''}
${p?.model_class === 1 ? `<div>${t('model.field.azure')}${(m?.connection?.openai?.by_azure === undefined) ? '' : (m?.connection?.openai?.by_azure ? 'true' : 'false')}</div><div>${t('model.field.api_version')}${m?.connection?.openai?.api_version || ''}</div>` : ''}
</div>
<div style="margin-top:8px;font-size:12px;color:#374151;word-break:break-all;">
${t('model.field.endpoint')}<span style="color:#3498db;">${baseUrl || '-'}</span>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
pageContent.innerHTML = html;
} catch (e) {
console.error(t('model.error.render_page_failed'), e);
showError(t('model.error.render_page_failed') + ': ' + e.message);
}
}
async function deleteModel(id) {
try {
if (!id) {
showError(t('model.error.delete_missing_id'));
return;
}
const confirmed = window.confirm(t('model.confirm.delete'));
if (!confirmed) {
return; // 用户取消
}
const resp = await fetch('/api/admin/config/model/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: String(id) })
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
let data;
try {
data = await resp.json();
} catch (err) {
showError(t('model.error.delete_invalid_json'));
return;
}
if (handleApiCode(data)) {
return;
}
if (data?.code !== 0) {
showError(t('model.error.delete_failed') + ': ' + (data?.msg || t('common.unknown_error')));
return;
}
// 刷新模型列表
await loadModelManagementPage();
} catch (error) {
showError(t('model.error.delete_failed') + ': ' + error.message);
}
}
function openAddModelModalWithProvider(btn) {
const modal = document.getElementById('addModelModal');
if (!modal) { showError(t('model.error.open_modal_failed')); return; }
const nameEl = document.getElementById('modalProviderName');
const iconEl = document.getElementById('modalProviderIcon');
const providerName = btn?.dataset?.providerName || t('model.provider.unknown');
const providerIcon = btn?.dataset?.providerIcon || '';
const providerClassStr = btn?.dataset?.providerClass;
const providerClass = providerClassStr ? Number(providerClassStr) : undefined;
window.__selectedProvider = { name: providerName, icon: providerIcon, model_class: providerClass };
if (nameEl) nameEl.textContent = providerName;
if (iconEl) {
if (providerIcon) {
iconEl.src = providerIcon; iconEl.alt = providerName; iconEl.style.display = 'block';
} else {
iconEl.src = ''; iconEl.style.display = 'none';
}
}
// 根据 provider 隐藏或显示 API Key 字段(Ollama 不需要)
const apiLabel = document.getElementById('newModelApiKeyLabel');
const apiInput = document.getElementById('newModelApiKey');
const isOllama = providerClass === 20; // developer_api.ModelClass_Llama
if (apiLabel) apiLabel.style.display = isOllama ? 'none' : '';
if (apiInput) apiInput.style.display = isOllama ? 'none' : '';
// 根据 provider 显示 Ark 的 Region 字段(仅 Doubao/Ark,class=2
const arkLabel = document.getElementById('newModelArkRegionLabel');
const arkInput = document.getElementById('newModelArkRegion');
const isArk = providerClass === 2;
if (arkLabel) arkLabel.style.display = isArk ? '' : 'none';
if (arkInput) arkInput.style.display = isArk ? '' : 'none';
// 根据 provider 显示 OpenAI 的 Azure 与 API Version 字段(仅 OpenAI,class=1
const openaiByAzureLabel = document.getElementById('newModelOpenAIByAzureLabel');
const openaiByAzureWrapper = document.getElementById('newModelOpenAIByAzureWrapper');
const openaiApiVersionLabel = document.getElementById('newModelOpenAIApiVersionLabel');
const openaiApiVersionInput = document.getElementById('newModelOpenAIApiVersion');
const isOpenAI = providerClass === 1;
if (openaiByAzureLabel) openaiByAzureLabel.style.display = isOpenAI ? '' : 'none';
if (openaiByAzureWrapper) openaiByAzureWrapper.style.display = isOpenAI ? 'flex' : 'none';
if (openaiApiVersionLabel) openaiApiVersionLabel.style.display = isOpenAI ? '' : 'none';
if (openaiApiVersionInput) openaiApiVersionInput.style.display = isOpenAI ? '' : 'none';
// 根据 provider 显示 Gemini 的后端、项目、位置(仅 Gemini,class=11
const geminiBackendLabel = document.getElementById('newModelGeminiBackendLabel');
const geminiBackendInput = document.getElementById('newModelGeminiBackend');
const geminiProjectLabel = document.getElementById('newModelGeminiProjectLabel');
const geminiProjectInput = document.getElementById('newModelGeminiProject');
const geminiLocationLabel = document.getElementById('newModelGeminiLocationLabel');
const geminiLocationInput = document.getElementById('newModelGeminiLocation');
const isGemini = providerClass === 11;
if (geminiBackendLabel) geminiBackendLabel.style.display = isGemini ? '' : 'none';
if (geminiBackendInput) geminiBackendInput.style.display = isGemini ? '' : 'none';
if (geminiProjectLabel) geminiProjectLabel.style.display = isGemini ? '' : 'none';
if (geminiProjectInput) geminiProjectInput.style.display = isGemini ? '' : 'none';
if (geminiLocationLabel) geminiLocationLabel.style.display = isGemini ? '' : 'none';
if (geminiLocationInput) geminiLocationInput.style.display = isGemini ? '' : 'none';
// Show/hide ThinkingType dropdown
const thinkingTypeLabel = document.getElementById('newModelThinkingTypeLabel');
const thinkingTypeSelect = document.getElementById('newModelThinkingType');
const thinkingTypeAutoOption = document.getElementById('thinkingTypeAutoOption');
const hasThinkingType = [2, 3, 11, 15, 20].includes(providerClass); // Ark, Claude, Gemini, Qwen, Ollama
const isArkProvider = providerClass === 2;
if (thinkingTypeLabel) thinkingTypeLabel.style.display = hasThinkingType ? '' : 'none';
if (thinkingTypeSelect) {
thinkingTypeSelect.style.display = hasThinkingType ? '' : 'none';
thinkingTypeSelect.value = '2'; // Reset to Disable
}
if (thinkingTypeAutoOption) thinkingTypeAutoOption.style.display = isArkProvider ? '' : 'none';
modal.style.display = 'flex';
}
function closeAddModelModal() {
const modal = document.getElementById('addModelModal');
if (modal) modal.style.display = 'none';
}
async function saveNewModel() {
const name = document.getElementById('newModelDisplayName')?.value?.trim() || '';
const model = document.getElementById('newModelModel')?.value?.trim() || '';
const apiKey = document.getElementById('newModelApiKey')?.value?.trim() || '';
const baseUrl = document.getElementById('newModelBaseUrl')?.value?.trim() || '';
// 允许 Base URL 为空;仅对必填项做校验,并在保存期间禁用按钮与显示提示
const submitBtn = document.getElementById('addModelSubmitBtn');
const restoreSavingState = () => {
if (submitBtn) {
submitBtn.disabled = false;
if (submitBtn.dataset.originalText) submitBtn.textContent = submitBtn.dataset.originalText;
submitBtn.style.opacity = '';
submitBtn.style.cursor = 'pointer';
}
};
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.dataset.originalText = submitBtn.textContent;
submitBtn.textContent = t('common.saving');
submitBtn.style.opacity = '0.7';
submitBtn.style.cursor = 'not-allowed';
}
const providerClass = window.__selectedProvider?.model_class;
if (typeof providerClass !== 'number') {
showError(t('model.error.provider_unrecognized'));
restoreSavingState();
return;
}
const requireApiKey = providerClass !== 20; // Ollama 不需要 API Key
if (!name || !model || (requireApiKey && !apiKey)) {
const missingMsg = requireApiKey ? t('model.error.missing_required_with_api') : t('model.error.missing_required');
showError(missingMsg);
restoreSavingState();
return;
}
// 构造 payload,Base URL 为空时不传该字段;Ollama 不传 api_key
const baseConn = { model: model };
if (baseUrl) baseConn.base_url = baseUrl;
if (requireApiKey && apiKey) baseConn.api_key = apiKey;
// 读取 ThinkingType(仅 Ark, Claude, Gemini, Qwen, Ollama
const thinkingTypeSelect = document.getElementById('newModelThinkingType');
const hasThinkingType = [2, 3, 11, 15, 20].includes(providerClass);
if (hasThinkingType && thinkingTypeSelect) {
const thinkingTypeValue = parseInt(thinkingTypeSelect.value, 10);
if (!Number.isNaN(thinkingTypeValue)) {
baseConn.thinking_type = thinkingTypeValue;
}
}
// 读取“启用 Base64 URL”复选框,默认 false
const enableBase64UrlEl = document.getElementById('newModelEnableBase64Url');
const enableBase64Url = !!(enableBase64UrlEl && enableBase64UrlEl.checked);
const arkRegionInput = document.getElementById('newModelArkRegion');
const arkRegion = arkRegionInput ? arkRegionInput.value.trim() : '';
const connection = { base_conn_info: baseConn };
if (providerClass === 2) {
connection.ark = { region: arkRegion };
}
// 读取 OpenAI 扩展字段(仅 providerClass=1
const openaiByAzureEl = document.getElementById('newModelOpenAIByAzure');
const openaiByAzure = !!(openaiByAzureEl && openaiByAzureEl.checked);
const openaiApiVersionEl = document.getElementById('newModelOpenAIApiVersion');
const openaiApiVersion = openaiApiVersionEl ? openaiApiVersionEl.value.trim() : '';
if (providerClass === 1 && (openaiByAzure || openaiApiVersion)) {
const openaiObj = {};
// 如果任一字段有值,则组装 openai 对象;by_azure 始终按当前开关状态传递
openaiObj.by_azure = openaiByAzure;
if (openaiApiVersion) openaiObj.api_version = openaiApiVersion;
connection.openai = openaiObj;
}
// 读取 Gemini 扩展字段(仅 providerClass=11),按需写入
const geminiBackendEl = document.getElementById('newModelGeminiBackend');
const geminiProjectEl = document.getElementById('newModelGeminiProject');
const geminiLocationEl = document.getElementById('newModelGeminiLocation');
const geminiBackendRaw = geminiBackendEl ? geminiBackendEl.value.trim() : '';
const hasGeminiBackend = geminiBackendRaw !== '';
const geminiBackend = hasGeminiBackend ? parseInt(geminiBackendRaw, 10) : NaN;
const geminiProject = geminiProjectEl ? geminiProjectEl.value.trim() : '';
const geminiLocation = geminiLocationEl ? geminiLocationEl.value.trim() : '';
if (providerClass === 11 && (hasGeminiBackend || geminiProject || geminiLocation)) {
const geminiObj = {};
if (hasGeminiBackend && !Number.isNaN(geminiBackend)) geminiObj.backend = geminiBackend;
if (geminiProject) geminiObj.project = geminiProject;
if (geminiLocation) geminiObj.location = geminiLocation;
connection.gemini = geminiObj;
}
const payload = {
model_name: name,
model_class: providerClass,
enable_base64_url: enableBase64Url,
connection
};
try {
const resp = await fetch('/api/admin/config/model/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
// 不依赖 resp.ok,统一按“JSON 优先,文本兜底”处理
let rawText = '';
try {
rawText = await resp.text();
} catch (e) {
showError(t('model.error.create_read_failed'));
return;
}
let data = null;
try {
data = JSON.parse(rawText);
} catch (e) {
data = null;
}
if (data && typeof data === 'object') {
if (handleApiCode(data)) {
return;
}
const code = Number(data.code);
if (code === 0) {
closeAddModelModal();
showSuccess(t('model.create.success'));
await loadModelManagementPage();
return;
}
showError(t('model.error.create_failed') + ': ' + (data?.msg || `HTTP ${resp.status}`));
return;
} else {
showError(`${t('model.error.create_failed')}: HTTP ${resp.status} ${rawText || ''}`);
return;
}
} catch (error) {
showError(t('model.error.create_failed') + ': ' + error.message);
} finally {
restoreSavingState();
}
}
// 知识库配置页面:加载与渲染(支持本地预览示例数据兜底)
async function loadKnowledgeConfigPage() {
ensureConfigStylesInjected();
try {
showLoading();
const currentHost = window.location.origin;
const apiUrl = `${currentHost}/api/admin/config/knowledge/get`;
const resp = await fetch(apiUrl, { method: 'GET', headers: { 'Accept': 'application/json' } });
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
let data;
try {
data = await resp.json();
} catch (err) {
if (isLocalPreview()) {
renderDemo();
return;
}
showError(t('knowledge.error.load_failed_json'));
return;
}
if (handleApiCode(data)) {
return;
}
if (typeof data?.code === 'number' && data.code !== 0) {
if (isLocalPreview()) { renderDemo(); return; }
showError(t('knowledge.error.load_failed_msg', { msg: (data?.msg || t('error.api_error')) }));
return;
}
if (!data?.knowledge_config) {
if (isLocalPreview()) {
renderDemo();
return;
}
showError(t('knowledge.error.load_failed_empty'));
return;
}
renderKnowledgeConfig(data.knowledge_config);
} catch (error) {
console.error(t('knowledge.error.load_failed') + ':', error);
if (isLocalPreview()) {
renderDemo();
return;
}
showError(t('knowledge.error.load_failed_reason', { reason: error.message }));
}
function isLocalPreview() {
const host = window.location.hostname;
return host === 'localhost' || host === '127.0.0.1';
}
function renderDemo() {
// 不再注入示例数据,使用空配置进行页面渲染以便本地预览 UI
renderKnowledgeConfig({});
console.info(t('knowledge.log.local_preview_no_demo'));
}
}
function formatValue(val) {
if (val === undefined || val === null) return '';
return String(val);
}
function renderKnowledgeConfig(cfg) {
try {
const embedding = cfg?.embedding_config || {};
const rerank = cfg?.rerank_config || {};
const ocr = cfg?.ocr_config || {};
const parser = cfg?.parser_config || {};
const builtinModelId = cfg?.builtin_model_id ?? '';
const conn = embedding?.connection || {};
const baseConn = conn?.base_conn_info || {};
const embeddingInfo = conn?.embedding_info || {};
const initial = {
embedding_type: embedding?.type ?? '',
embedding_max_batch_size: Number(embedding?.max_batch_size ?? 100),
ark_base_url: baseConn?.base_url || '',
ark_api_key: baseConn?.api_key || '',
ark_region: conn?.ark?.region || '',
ark_model: baseConn?.model || '',
ark_embedding_dims: embeddingInfo?.dims ?? '0',
ark_embedding_api_type: conn?.ark?.embedding_api_type || conn?.ark?.api_type || '',
openai_base_url: baseConn?.base_url || '',
openai_api_key: baseConn?.api_key || '',
openai_model: baseConn?.model || '',
openai_embedding_dims: embeddingInfo?.dims ?? '0',
openai_embedding_by_azure: Boolean(conn?.openai?.embedding_by_azure ?? conn?.openai?.by_azure),
openai_embedding_api_version: conn?.openai?.embedding_api_version ?? conn?.openai?.api_version ?? '',
ollama_base_url: baseConn?.base_url || '',
ollama_model: baseConn?.model || '',
ollama_embedding_dims: embeddingInfo?.dims ?? '0',
gemini_base_url: baseConn?.base_url || '',
gemini_api_key: baseConn?.api_key || '',
gemini_model: baseConn?.model || '',
gemini_embedding_dims: embeddingInfo?.dims ?? '0',
gemini_embedding_backend: conn?.gemini?.embedding_backend ?? conn?.gemini?.backend ?? '',
gemini_embedding_project: conn?.gemini?.embedding_project ?? conn?.gemini?.project ?? '',
gemini_embedding_location: conn?.gemini?.embedding_location ?? conn?.gemini?.location ?? '',
http_address: conn?.http?.address || '',
http_dims: embeddingInfo?.dims ?? '0',
rerank_type: rerank?.type ?? '',
vik_ak: rerank?.vikingdb_config?.ak || '',
vik_sk: rerank?.vikingdb_config?.sk || '',
vik_host: rerank?.vikingdb_config?.host || '',
vik_region: rerank?.vikingdb_config?.region || '',
vik_model: rerank?.vikingdb_config?.model || '',
ocr_type: ocr?.type ?? '',
ocr_ark_ak: ocr?.ark_ak || ocr?.volcengine_ak || '',
ocr_ark_sk: ocr?.ark_sk || ocr?.volcengine_sk || '',
ocr_paddleocr_api_url: ocr?.paddleocr_api_url || '',
parser_type: parser?.type ?? '',
parser_paddleocr_structure_api_url: parser?.paddleocr_structure_api_url || '',
builtin_model_id: builtinModelId ?? ''
};
if (initial.embedding_type==0) {
initial.openai_api_key ='';
initial.openai_base_url ='';
initial.openai_model ='';
initial.openai_embedding_dims ='';
initial.ollama_base_url ='';
initial.ollama_model ='';
initial.ollama_embedding_dims ='';
initial.gemini_base_url ='';
initial.gemini_api_key ='';
initial.gemini_model ='';
initial.gemini_embedding_dims ='';
initial.http_address ='';
initial.http_dims ='';
}else if (initial.embedding_type==1) {
initial.ark_base_url ='';
initial.ark_api_key ='';
initial.ark_region ='';
initial.ark_model ='';
initial.ark_embedding_dims ='';
initial.ollama_base_url ='';
initial.ollama_model ='';
initial.ollama_embedding_dims ='';
initial.gemini_base_url ='';
initial.gemini_api_key ='';
initial.gemini_model ='';
initial.gemini_embedding_dims ='';
initial.http_address ='';
initial.http_dims ='';
} else if (initial.embedding_type==2) {
initial.openai_api_key ='';
initial.openai_base_url ='';
initial.openai_model ='';
initial.openai_embedding_dims ='';
initial.ark_base_url ='';
initial.ark_api_key ='';
initial.ark_region ='';
initial.ark_model ='';
initial.ark_embedding_dims ='';
initial.gemini_base_url ='';
initial.gemini_api_key ='';
initial.gemini_model ='';
initial.http_address ='';
initial.http_dims ='';
} else if (initial.embedding_type==3) {
initial.openai_api_key ='';
initial.openai_base_url ='';
initial.openai_model ='';
initial.openai_embedding_dims ='';
initial.ark_base_url ='';
initial.ark_api_key ='';
initial.ark_region ='';
initial.ark_model ='';
initial.ark_embedding_dims ='';
initial.ollama_base_url ='';
initial.ollama_model ='';
initial.ollama_embedding_dims ='';
initial.http_address ='';
initial.http_dims ='';
} else if (initial.embedding_type==4) {
initial.openai_api_key ='';
initial.openai_base_url ='';
initial.openai_model ='';
initial.openai_embedding_dims ='';
initial.ark_base_url ='';
initial.ark_api_key ='';
initial.ark_region ='';
initial.ark_model ='';
initial.ark_embedding_dims ='';
initial.ollama_base_url ='';
initial.ollama_model ='';
initial.ollama_embedding_dims ='';
initial.gemini_base_url ='';
initial.gemini_api_key ='';
initial.gemini_model ='';
initial.gemini_embedding_dims ='';
}
const inputStyle = "width:100%;padding:8px;border:1px solid #e1e8ed;border-radius:6px;font-size:12px;";
const labelStyle = "font-size:12px;color:#374151;margin-bottom:6px;";
const groupGrid = "display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;";
const html = `
<div style="padding: 30px;">
<div style="display:grid;grid-template-columns:1fr;gap:20px;">
<!-- Group 1: Embedding 配置 -->
<div style="background:white;border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.08);border:1px solid #e1e8ed;overflow:hidden;">
<div style="padding:16px;border-bottom:1px solid #f1f3f4;background:#f8f9fa;">
<div style="font-weight:600;color:#2c3e50;">${t('knowledge.section.embedding')}</div>
</div>
<div style="padding:16px;">
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">Type</div>
<select id="kb_embedding_type" style="${inputStyle}" onchange="onEmbeddingTypeChange()">
<option value="0" ${Number(initial.embedding_type) === 0 ? 'selected' : ''}>Ark</option>
<option value="1" ${Number(initial.embedding_type) === 1 ? 'selected' : ''}>OpenAI</option>
<option value="2" ${Number(initial.embedding_type) === 2 ? 'selected' : ''}>Ollama</option>
<option value="3" ${Number(initial.embedding_type) === 3 ? 'selected' : ''}>Gemini</option>
<option value="4" ${Number(initial.embedding_type) === 4 ? 'selected' : ''}>HTTP</option>
</select>
</div>
<div>
<div style="${labelStyle}">Max Batch Size</div>
<input id="kb_embedding_max_batch_size" type="number" style="${inputStyle}" value="${initial.embedding_max_batch_size}" />
</div>
</div>
<div id="kb_conn_ark" style="margin-top:12px;border:1px solid #f1f3f4;border-radius:8px;padding:12px;">
<div style="font-weight:600;color:#2c3e50;margin-bottom:8px;">${t('knowledge.connection', { name: 'Ark' })}</div>
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">Base URL<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_ark_base_url" type="text" style="${inputStyle}" value="${initial.ark_base_url}" />
</div>
<div>
<div style="${labelStyle}">API Key<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_ark_api_key" type="text" style="${inputStyle}" value="${initial.ark_api_key}" />
</div>
<div style="display:none;">
<div style="${labelStyle}">Region</div>
<input id="kb_ark_region" type="text" style="${inputStyle}" value="${initial.ark_region}" />
</div>
<div>
<div style="${labelStyle}">Model<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_ark_model" type="text" style="${inputStyle}" value="${initial.ark_model}" />
</div>
<div>
<div style="${labelStyle}">Dims</div>
<input id="kb_ark_embedding_dims" type="number" min="0" style="${inputStyle}" value="${initial.ark_embedding_dims}" />
</div>
<div>
<div style="${labelStyle}">API Type</div>
<select id="kb_ark_embedding_api_type" style="${inputStyle}">
<option value="text_api" ${initial.ark_embedding_api_type === 'text_api' ? 'selected' : ''}>text_api</option>
<option value="multi_modal_api" ${initial.ark_embedding_api_type === 'multi_modal_api' ? 'selected' : ''}>multi_modal_api</option>
</select>
</div>
</div>
</div>
<div id="kb_conn_openai" style="margin-top:12px;border:1px solid #f1f3f4;border-radius:8px;padding:12px;">
<div style="font-weight:600;color:#2c3e50;margin-bottom:8px;">${t('knowledge.connection', { name: 'OpenAI' })}</div>
<div style="${groupGrid}">
<!-- 必填字段优先显示:Base URL、Model、API Key、Dims(红点) -->
<div>
<div style="${labelStyle}">Base URL<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_openai_base_url" type="text" style="${inputStyle}" value="${initial.openai_base_url}" />
</div>
<div>
<div style="${labelStyle}">Model<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_openai_model" type="text" style="${inputStyle}" value="${initial.openai_model}" />
</div>
<div>
<div style="${labelStyle}">API Key<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_openai_api_key" type="text" style="${inputStyle}" value="${initial.openai_api_key}" />
</div>
<div>
<div style="${labelStyle}">Dims</div>
<input id="kb_openai_embedding_dims" type="number" min="0" style="${inputStyle}" value="${initial.openai_embedding_dims}" />
</div>
<!-- 选填字段靠后显示:By Azure、API Version、Request Dims -->
<div>
<div style="${labelStyle}">By Azure</div>
<input id="kb_openai_embedding_by_azure" type="checkbox" ${initial.openai_embedding_by_azure ? 'checked' : ''} />
</div>
<div>
<div style="${labelStyle}">Embedding API Version</div>
<input id="kb_openai_embedding_api_version" type="text" style="${inputStyle}" value="${initial.openai_embedding_api_version}" />
</div>
</div>
</div>
<div id="kb_conn_ollama" style="margin-top:12px;border:1px solid #f1f3f4;border-radius:8px;padding:12px;">
<div style="font-weight:600;color:#2c3e50;margin-bottom:8px;">${t('knowledge.connection', { name: 'Ollama' })}</div>
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">Base URL<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_ollama_base_url" type="text" style="${inputStyle}" value="${initial.ollama_base_url}" />
</div>
<div>
<div style="${labelStyle}">Model<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_ollama_model" type="text" style="${inputStyle}" value="${initial.ollama_model}" />
</div>
<div>
<div style="${labelStyle}">Dims</div>
<input id="kb_ollama_embedding_dims" type="number" min="0" style="${inputStyle}" value="${initial.ollama_embedding_dims}" />
</div>
</div>
</div>
<div id="kb_conn_gemini" style="margin-top:12px;border:1px solid #f1f3f4;border-radius:8px;padding:12px;">
<div style="font-weight:600;color:#2c3e50;margin-bottom:8px;">${t('knowledge.connection', { name: 'Gemini' })}</div>
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">Base URL<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_gemini_base_url" type="text" style="${inputStyle}" value="${initial.gemini_base_url}" />
</div>
<div>
<div style="${labelStyle}">API Key<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_gemini_api_key" type="text" style="${inputStyle}" value="${initial.gemini_api_key}" />
</div>
<div>
<div style="${labelStyle}">Model<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_gemini_model" type="text" style="${inputStyle}" value="${initial.gemini_model}" />
</div>
<div>
<div style="${labelStyle}">Backend<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_gemini_embedding_backend" type="number" style="${inputStyle}" value="${initial.gemini_embedding_backend}" />
</div>
<div>
<div style="${labelStyle}">Dims</div>
<input id="kb_gemini_embedding_dims" type="number" min="0" style="${inputStyle}" value="${initial.gemini_embedding_dims}" />
</div>
<div>
<div style="${labelStyle}">Project</div>
<input id="kb_gemini_embedding_project" type="text" style="${inputStyle}" value="${initial.gemini_embedding_project}" />
</div>
<div>
<div style="${labelStyle}">Location</div>
<input id="kb_gemini_embedding_location" type="text" style="${inputStyle}" value="${initial.gemini_embedding_location}" />
</div>
</div>
</div>
<div id="kb_conn_http" style="margin-top:12px;border:1px solid #f1f3f4;border-radius:8px;padding:12px;">
<div style="font-weight:600;color:#2c3e50;margin-bottom:8px;">${t('knowledge.connection', { name: 'HTTP' })}</div>
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">Address<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_http_address" type="text" style="${inputStyle}" value="${initial.http_address}" />
</div>
<div>
<div style="${labelStyle}">Dims</div>
<input id="kb_http_dims" type="number" min="0" style="${inputStyle}" value="${initial.http_dims}" />
</div>
</div>
</div>
</div>
</div>
<!-- Group 2: Rerank 配置 -->
<div style="background:white;border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.08);border:1px solid #e1e8ed;overflow:hidden;">
<div style="padding:16px;border-bottom:1px solid #f1f3f4;background:#f8f9fa;">
<div style="font-weight:600;color:#2c3e50;">${t('knowledge.section.rerank')}</div>
</div>
<div style="padding:16px;">
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">Type</div>
<select id="kb_rerank_type" style="${inputStyle}" onchange="onRerankTypeChange()">
<option value="0" ${Number(initial.rerank_type) === 0 ? 'selected' : ''}>VikingDB</option>
<option value="1" ${Number(initial.rerank_type) === 1 ? 'selected' : ''}>RRF</option>
</select>
</div>
</div>
<div id="kb_conn_vikingdb" style="margin-top:12px;border:1px solid #f1f3f4;border-radius:8px;padding:12px;">
<div style="font-weight:600;color:#2c3e50;margin-bottom:8px;">VikingDB</div>
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">AK<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_vik_ak" type="text" style="${inputStyle}" value="${initial.vik_ak}" />
</div>
<div>
<div style="${labelStyle}">SK<span style="display:inline-block;width:6px;height:6px;background:#e74c3c;border-radius:50%;margin-left:6px;"></span></div>
<input id="kb_vik_sk" type="text" style="${inputStyle}" value="${initial.vik_sk}" />
</div>
<div>
<div style="${labelStyle}">Host</div>
<input id="kb_vik_host" type="text" style="${inputStyle}" value="${initial.vik_host}" />
</div>
<div>
<div style="${labelStyle}">Region</div>
<input id="kb_vik_region" type="text" style="${inputStyle}" value="${initial.vik_region}" />
</div>
<div>
<div style="${labelStyle}">Model</div>
<input id="kb_vik_model" type="text" style="${inputStyle}" value="${initial.vik_model}" />
</div>
</div>
</div>
</div>
</div>
<!-- Group 3: OCR 配置 -->
<div style="background:white;border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.08);border:1px solid #e1e8ed;overflow:hidden;">
<div style="padding:16px;border-bottom:1px solid #f1f3f4;background:#f8f9fa;">
<div style="font-weight:600;color:#2c3e50;">${t('knowledge.section.ocr')}</div>
</div>
<div style="padding:16px;">
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">Type</div>
<select id="kb_ocr_type" style="${inputStyle}" onchange="onOcrTypeChange()">
<option value="0" ${Number(initial.ocr_type) === 0 ? 'selected' : ''}>Volcengine</option>
<option value="1" ${Number(initial.ocr_type) === 1 ? 'selected' : ''}>Paddleocr</option>
</select>
</div>
<div id="kb_conn_ocr_volcengine">
<div style="${labelStyle}">Volcengine AK</div>
<input id="kb_ocr_ark_ak" type="text" style="${inputStyle}" value="${initial.ocr_ark_ak}" />
<div style="${labelStyle};margin-top:12px;">Volcengine SK</div>
<input id="kb_ocr_ark_sk" type="text" style="${inputStyle}" value="${initial.ocr_ark_sk}" />
</div>
<div id="kb_conn_ocr_paddleocr">
<div style="${labelStyle}">PaddleOCR API URL</div>
<input id="kb_ocr_paddleocr_api_url" type="text" style="${inputStyle}" value="${initial.ocr_paddleocr_api_url}" />
</div>
</div>
</div>
</div>
<!-- Group 4: Parser 配置 -->
<div style="background:white;border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.08);border:1px solid #e1e8ed;overflow:hidden;">
<div style="padding:16px;border-bottom:1px solid #f1f3f4;background:#f8f9fa;">
<div style="font-weight:600;color:#2c3e50;">${t('knowledge.section.parser')}</div>
</div>
<div style="padding:16px;">
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">Type</div>
<select id="kb_parser_type" style="${inputStyle}" onchange="onParserTypeChange()">
<option value="0" ${Number(initial.parser_type) === 0 ? 'selected' : ''}>Builtin</option>
<option value="1" ${Number(initial.parser_type) === 1 ? 'selected' : ''}>Paddleocr</option>
</select>
</div>
<div id="kb_conn_parser_paddleocr">
<div style="${labelStyle}">PaddleOCR API URL</div>
<input id="kb_parser_paddleocr_structure_api_url" type="text" style="${inputStyle}" value="${initial.parser_paddleocr_structure_api_url}" />
</div>
</div>
</div>
</div>
<!-- Group 5: 内置模型配置 -->
<div id="kb_builtin_model_group" style="background:white;border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.08);border:1px solid #e1e8ed;overflow:hidden;">
<div style="padding:16px;border-bottom:1px solid #f1f3f4;background:#f8f9fa;">
<div style="font-weight:600;color:#2c3e50;">${t('knowledge.section.builtin_model')}</div>
</div>
<div style="padding:16px;">
<div style="${groupGrid}">
<div>
<div style="${labelStyle}">${t('knowledge.label.choose_builtin_model')}</div>
<div id="kb_builtin_model_select" style="position:relative;">
<div id="kb_builtin_model_selected" style="display:flex;align-items:center;gap:8px;border:1px solid #e1e8ed;border-radius:8px;padding:8px;cursor:pointer;">
<img id="kb_builtin_model_selected_icon" src="" alt="" style="width:20px;height:20px;border-radius:4px;object-fit:contain;display:none;">
<span id="kb_builtin_model_selected_name" style="color:#2c3e50;">${t('knowledge.placeholder.select_model')}</span>
<span style="margin-left:auto;color:#7b8a8b;">▼</span>
</div>
<div id="kb_builtin_model_dropdown" style="margin-top:8px;background:#fff;border:1px solid #e1e8ed;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.08);max-height:240px;overflow:auto;display:none;z-index:10;">
<div id="kb_builtin_model_loading" style="padding:8px;color:#7b8a8b;">${t('knowledge.loading.model_list')}</div>
</div>
</div>
<input id="kb_builtin_model_id" type="hidden" value="${initial.builtin_model_id}" />
</div>
</div>
</div>
</div>
</div>
<div class="action-buttons">
<button type="button" class="btn btn-primary" onclick="saveKnowledgeConfig()">💾 ${t('btn.save')}</button>
</div>
</div>
`;
pageContent.innerHTML = html;
try {
const builtinGroup = document.getElementById('kb_builtin_model_group');
if (builtinGroup && builtinGroup.parentElement) {
builtinGroup.parentElement.insertBefore(builtinGroup, builtinGroup.parentElement.firstElementChild);
}
} catch (_) {}
try { setEmbeddingTypeUI(Number(initial.embedding_type ?? 0)); } catch (_) {}
try { setRerankTypeUI(Number(initial.rerank_type ?? 0)); } catch (_) {}
try { setOcrTypeUI(Number(initial.ocr_type ?? 0)); } catch (_) {}
try { setParserTypeUI(Number(initial.parser_type ?? 0)); } catch (_) {}
try { initBuiltinModelSelect(initial.builtin_model_id); } catch (_) {}
} catch (e) {
console.error(t('knowledge.error.render_failed') + ':', e);
showError(t('knowledge.error.render_failed') + ': ' + e.message);
}
}
// 显示加载状态}
// 内置模型下拉:初始化与渲染
function initBuiltinModelSelect(initialModelId) {
try {
const container = document.getElementById('kb_builtin_model_select');
const selected = document.getElementById('kb_builtin_model_selected');
const nameEl = document.getElementById('kb_builtin_model_selected_name');
const iconEl = document.getElementById('kb_builtin_model_selected_icon');
const dropdownEl = document.getElementById('kb_builtin_model_dropdown');
const hiddenInput = document.getElementById('kb_builtin_model_id');
if (!container || !selected || !dropdownEl || !hiddenInput) { return; }
const normalizedInitialId = (initialModelId === undefined || initialModelId === null) ? undefined : String(initialModelId).trim();
// toggle dropdown
selected.onclick = function() {
const isOpen = dropdownEl.style.display !== 'none';
dropdownEl.style.display = isOpen ? 'none' : 'block';
};
document.addEventListener('click', function(e) {
if (!container.contains(e.target)) {
dropdownEl.style.display = 'none';
}
});
dropdownEl.innerHTML = '<div id="kb_builtin_model_loading" style="padding:8px;color:#7b8a8b;">' + t('knowledge.loading.model_list') + '</div>';
fetch('/api/admin/config/model/list', { method: 'GET' })
.then(resp => resp.json())
.then(data => {
if (handleApiCode(data)) {
return;
}
const groups = data?.provider_model_list ?? data?.ProviderModelList ?? [];
dropdownEl.innerHTML = '';
if (!Array.isArray(groups) || groups.length === 0) {
dropdownEl.innerHTML = '<div style="padding:8px;color:#bdc3c7;">' + t('knowledge.empty.model_list') + '</div>';
return;
}
let selectedFound = false;
groups.forEach(group => {
const provider = group.provider ?? group.Provider;
const icon = provider?.icon_url ?? provider?.IconURL ?? '';
const models = group.model_list ?? group.ModelList ?? [];
models.forEach(m => {
const id = m.id ?? m.ID;
const name = (m.display_info?.name) ?? (m.DisplayInfo?.Name);
const opt = document.createElement('div');
opt.style.display = 'flex';
opt.style.alignItems = 'center';
opt.style.gap = '8px';
opt.style.padding = '8px';
opt.style.cursor = 'pointer';
opt.style.borderBottom = '1px solid #f1f3f4';
opt.onmouseenter = function(){ opt.style.background = '#f8f9fa'; };
opt.onmouseleave = function(){ opt.style.background = '#fff'; };
const img = document.createElement('img');
img.src = icon || '';
img.alt = '';
img.style.width = '20px';
img.style.height = '20px';
img.style.borderRadius = '4px';
img.style.objectFit = 'contain';
img.style.display = icon ? 'block' : 'none';
const span = document.createElement('span');
span.textContent = name || t('knowledge.placeholder.model_fallback', { id });
opt.appendChild(img);
opt.appendChild(span);
opt.onclick = function() {
hiddenInput.value = String(id);
nameEl.textContent = span.textContent;
if (icon) { iconEl.src = icon; iconEl.style.display = 'block'; } else { iconEl.style.display = 'none'; }
dropdownEl.style.display = 'none';
};
dropdownEl.appendChild(opt);
if (!selectedFound && normalizedInitialId !== undefined && normalizedInitialId !== '' && Number(normalizedInitialId) === Number(id)) {
selectedFound = true;
hiddenInput.value = String(id);
nameEl.textContent = span.textContent;
if (icon) { iconEl.src = icon; iconEl.style.display = 'block'; } else { iconEl.style.display = 'none'; }
}
});
});
if (!selectedFound) {
const first = dropdownEl.firstElementChild;
if (first) {
first.click();
} else {
nameEl.textContent = t('knowledge.tip.setup_model_first');
iconEl.style.display = 'none';
}
}
})
.catch(err => {
dropdownEl.innerHTML = '<div style="padding:8px;color:#e74c3c;">' + t('knowledge.error.model_list_load_failed') + '</div>';
console.error(t('knowledge.error.model_list_load_failed') + ':', err);
});
} catch (e) {
console.error('初始化内置模型下拉失败:', e);
}
}
function buildKnowledgePayloadFromInputs() {
const val = (id) => {
const el = document.getElementById(id);
return el ? el.value : '';
};
const checked = (id) => {
const el = document.getElementById(id);
return el ? !!el.checked : false;
};
const toNum = (v) => {
const n = Number(v);
return isNaN(n) ? undefined : n;
};
const typeVal = toNum(val('kb_embedding_type'));
// 根据选择的 embedding type,抽取 base_conn_info 与 dims
const baseConnInfo = (() => {
switch (typeVal) {
case 0: // Ark
return {
base_url: val('kb_ark_base_url'),
api_key: val('kb_ark_api_key'),
model: val('kb_ark_model')
};
case 1: // OpenAI
return {
base_url: val('kb_openai_base_url'),
api_key: val('kb_openai_api_key'),
model: val('kb_openai_model')
};
case 2: // Ollama
return {
base_url: val('kb_ollama_base_url'),
model: val('kb_ollama_model')
};
case 3: // Gemini
return {
base_url: val('kb_gemini_base_url'),
api_key: val('kb_gemini_api_key'),
model: val('kb_gemini_model')
};
case 4: // HTTP
default:
return { base_url: '', api_key: '', model: '' };
}
})();
const dimsVal = (() => {
switch (typeVal) {
case 0: return toNum(val('kb_ark_embedding_dims'));
case 1: return toNum(val('kb_openai_embedding_dims'));
case 2: return toNum(val('kb_ollama_embedding_dims'));
case 3: return toNum(val('kb_gemini_embedding_dims'));
case 4: return toNum(val('kb_http_dims'));
default: return undefined;
}
})();
return {
knowledge_config: {
embedding_config: {
type: typeVal,
max_batch_size: toNum(val('kb_embedding_max_batch_size')) ?? 100,
connection: {
base_conn_info: baseConnInfo,
embedding_info: { dims: dimsVal },
ark: {
api_type: val('kb_ark_embedding_api_type')
},
openai: {
by_azure: checked('kb_openai_embedding_by_azure'),
api_version: val('kb_openai_embedding_api_version')
},
gemini: {
backend: toNum(val('kb_gemini_embedding_backend')),
project: val('kb_gemini_embedding_project'),
location: val('kb_gemini_embedding_location')
},
http: {
address: val('kb_http_address')
}
}
},
rerank_config: {
type: toNum(val('kb_rerank_type')),
vikingdb_config: {
ak: val('kb_vik_ak'),
sk: val('kb_vik_sk'),
host: val('kb_vik_host'),
region: val('kb_vik_region'),
model: val('kb_vik_model')
}
},
ocr_config: {
type: toNum(val('kb_ocr_type')),
volcengine_ak: val('kb_ocr_ark_ak'),
volcengine_sk: val('kb_ocr_ark_sk'),
paddleocr_api_url: val('kb_ocr_paddleocr_api_url')
},
parser_config: {
type: toNum(val('kb_parser_type')),
paddleocr_structure_api_url: val('kb_parser_paddleocr_structure_api_url')
},
builtin_model_id: toNum(val('kb_builtin_model_id'))
}
};
}
function saveKnowledgeConfig() {
try {
const payload = buildKnowledgePayloadFromInputs();
// 初始化 Rerank 配置显示状态(防止未选择时错误展示)
try { setRerankTypeUI(Number(document.getElementById('kb_rerank_type')?.value || 0)); } catch (_) {}
// Embedding 必填项校验(不同类型不同必填要求)
const typeVal = Number(document.getElementById('kb_embedding_type')?.value);
const get = (id) => (document.getElementById(id)?.value || '').trim();
const getDims = (id) => {
const el = document.getElementById(id);
return el ? Number(el.value) : 0;
};
if (typeVal === 0) {
// Ark: Base URL、API Key、Model、Dims 必填,API Type 可选
const dimsVal = getDims('kb_ark_embedding_dims');
const missing = [];
if (!get('kb_ark_base_url')) missing.push('Base URL');
if (!get('kb_ark_api_key')) missing.push('API Key');
if (!get('kb_ark_model')) missing.push('Model');
if (dimsVal < 0) {
showError('Dims must be >= 0');
return;
}
if (missing.length > 0) {
showError(t('error.missing_required', { list: missing.join(', ') }));
return;
}
} else if (typeVal === 1) {
// OpenAI: Base URL、Model、API Key、Dims 必填
const dimsVal = getDims('kb_openai_embedding_dims');
const missing = [];
if (!get('kb_openai_base_url')) missing.push('Base URL');
if (!get('kb_openai_model')) missing.push('Model');
if (!get('kb_openai_api_key')) missing.push('API Key');
if (dimsVal < 0) {
showError('Dims must be >= 0');
return;
}
if (missing.length > 0) {
showError(t('error.missing_required', { list: missing.join(', ') }));
return;
}
} else if (typeVal === 2) {
// Ollama: Base URL、Model、Dims 必填
const dimsVal = getDims('kb_ollama_embedding_dims');
const missing = [];
if (!get('kb_ollama_base_url')) missing.push('Base URL');
if (!get('kb_ollama_model')) missing.push('Model');
if (dimsVal < 0) {
showError('Dims must be >= 0');
return;
}
if (missing.length > 0) {
showError(t('error.missing_required', { list: missing.join(', ') }));
return;
}
} else if (typeVal === 3) {
// Gemini: Base URL、API Key、Model、Dims、Backend 必填;Project、Location 选填
const dimsVal = getDims('kb_gemini_embedding_dims');
const backendStr = (document.getElementById('kb_gemini_embedding_backend')?.value || '').trim();
const missing = [];
if (!get('kb_gemini_base_url')) missing.push('Base URL');
if (!get('kb_gemini_api_key')) missing.push('API Key');
if (!get('kb_gemini_model')) missing.push('Model');
if (!backendStr) missing.push('Backend');
if (dimsVal < 0) {
showError('Dims must be >= 0');
return;
}
if (missing.length > 0) {
showError(t('error.missing_required', { list: missing.join(', ') }));
return;
}
} else if (typeVal === 4) {
// HTTP: Address、Dims 必填
const dimsVal = getDims('kb_http_dims');
const missing = [];
if (!get('kb_http_address')) missing.push('Address');
if (dimsVal < 0) {
showError('Dims must be >= 0');
return;
}
if (missing.length > 0) {
showError(t('error.missing_required', { list: missing.join(', ') }));
return;
}
}
// Rerank 必填项校验:VikingDB 的 AK、SK 必填,其它项选填
try {
const rerankTypeVal = Number(document.getElementById('kb_rerank_type')?.value);
if (rerankTypeVal === 0) {
const missing = [];
if (!get('kb_vik_ak')) missing.push('AK');
if (!get('kb_vik_sk')) missing.push('SK');
if (missing.length > 0) {
showError(t('error.missing_required', { list: missing.join(', ') }));
return;
}
}
} catch (_) {}
// showOverlay(t('overlay.saving'));
fetch('/api/admin/config/knowledge/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(async (resp) => {
let bodyText = '';
try { bodyText = await resp.text(); } catch (_) {}
let data = null;
try { data = JSON.parse(bodyText); } catch (_) { data = null; }
if (data && typeof data === 'object') {
if (handleApiCode(data)) { hideOverlay(); return; }
if (Number(data.code) === 0) { hideOverlay(); showSuccess(t('success.saved_restart_required')); return; }
hideOverlay(); showError(t('error.save_failed_reason', { reason: (data?.msg || t('error.api_error')) })); return;
}
if (!resp.ok) {
hideOverlay();
throw new Error(bodyText || ('HTTP ' + resp.status));
}
hideOverlay();
showSuccess(t('success.saved_restart_required'));
}).catch((err) => {
hideOverlay();
console.error('Failed to save knowledge configuration:', err);
showError(t('error.save_failed_reason', { reason: (err?.message || String(err)) }));
});
} catch (e) {
hideOverlay();
console.error('Knowledge configuration save exception:', e);
showError(t('error.save_failed_reason', { reason: e.message }));
}
}
function setEmbeddingTypeUI(typeVal) {
const ids = ['kb_conn_ark','kb_conn_openai','kb_conn_ollama','kb_conn_gemini','kb_conn_http'];
ids.forEach((id, idx) => {
const el = document.getElementById(id);
if (el) el.style.display = (Number(typeVal) === idx ? 'block' : 'none');
});
}
function setRerankTypeUI(typeVal) {
try {
const vik = document.getElementById('kb_conn_vikingdb');
if (!vik) return;
vik.style.display = Number(typeVal) === 0 ? '' : 'none';
} catch (_) {}
}
function onRerankTypeChange() {
try {
const v = Number(document.getElementById('kb_rerank_type')?.value);
setRerankTypeUI(v);
} catch (_) {}
}
function setOcrTypeUI(typeVal) {
try {
const volc = document.getElementById('kb_conn_ocr_volcengine');
const pad = document.getElementById('kb_conn_ocr_paddleocr');
if (!volc || !pad) return;
const t = Number(typeVal);
volc.style.display = t === 0 ? '' : 'none';
pad.style.display = t === 1 ? '' : 'none';
} catch (_) {}
}
function onOcrTypeChange() {
try {
const v = Number(document.getElementById('kb_ocr_type')?.value);
setOcrTypeUI(v);
} catch (_) {}
}
function setParserTypeUI(typeVal) {
try {
const pad = document.getElementById('kb_conn_parser_paddleocr');
if (!pad) return;
pad.style.display = Number(typeVal) === 1 ? '' : 'none';
} catch (_) {}
}
function onParserTypeChange() {
try {
const v = Number(document.getElementById('kb_parser_type')?.value);
setParserTypeUI(v);
} catch (_) {}
}
function onEmbeddingTypeChange() {
const sel = document.getElementById('kb_embedding_type');
const val = sel ? Number(sel.value) : 0;
setEmbeddingTypeUI(val);
}
function showLoading() {
pageContent.innerHTML = `
<div class="loading">
<div class="loading-spinner"></div>
<span>${t('loading')}</span>
</div>
`;
}
// 统一成功提示(透明浮层)
function showSuccess(message) {
showOverlay(message || t('success.generic'), 'success');
}
// 统一错误提示(透明浮层)
function showError(message) {
showOverlay(message || t('error.generic'), 'error');
}
// 透明浮层:显示(与 config.html 保持一致)
function showOverlay(message, type) {
const overlay = document.getElementById('overlay');
const overlayContent = document.getElementById('overlayContent');
const overlayMessage = document.getElementById('overlayMessage');
if (!overlay || !overlayContent || !overlayMessage) {
alert(String(message || t('error.generic')));
return;
}
overlayMessage.textContent = String(message || '');
overlayContent.className = `overlay-content ${type === 'success' ? 'success' : 'error'}`;
overlay.classList.add('show');
// 背景点击关闭
overlay.onclick = function(e) {
if (e.target === overlay) {
hideOverlay();
}
};
// 3秒后自动隐藏
setTimeout(() => hideOverlay(), 3000);
}
// 透明浮层:隐藏
function hideOverlay() {
const overlay = document.getElementById('overlay');
if (overlay) {
overlay.classList.remove('show');
}
}
// 加载外部页面
function loadPage(filename) {
showLoading();
fetch(filename)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.text();
})
.then(html => {
// 解析HTML并提取内容
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const title = doc.querySelector('title')?.textContent || t('page.default.title');
const content = doc.querySelector('.page-content')?.innerHTML ||
doc.querySelector('body')?.innerHTML ||
`<p>${t('page.empty')}</p>`;
pageTitle.textContent = title;
pageDescription.textContent = t('page.manage_config', { title });
pageContent.innerHTML = content;
// 执行页面中的脚本
const scripts = doc.querySelectorAll('script');
scripts.forEach(script => {
if (script.textContent) {
try {
eval(script.textContent);
} catch (e) {
console.warn('执行页面脚本失败:', e);
}
}
});
})
.catch(error => {
console.error('加载页面失败:', error);
showError(t('error.page_load_failed_reason', { reason: error.message }));
});
}
// 快速操作函数
function showSystemStatus() {
pageContent.innerHTML = `
<div style="padding: 30px;">
<h2 style="margin-bottom: 20px;">📊 系统状态</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px;">
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745;">
<h4 style="color: #28a745; margin: 0 0 10px;">服务状态</h4>
<p style="margin: 0; font-size: 24px; font-weight: bold;">🟢 正常运行</p>
</div>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #007bff;">
<h4 style="color: #007bff; margin: 0 0 10px;">CPU 使用率</h4>
<p style="margin: 0; font-size: 24px; font-weight: bold;">23%</p>
</div>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #ffc107;">
<h4 style="color: #ffc107; margin: 0 0 10px;">内存使用</h4>
<p style="margin: 0; font-size: 24px; font-weight: bold;">1.2GB / 4GB</p>
</div>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #17a2b8;">
<h4 style="color: #17a2b8; margin: 0 0 10px;">运行时间</h4>
<p style="margin: 0; font-size: 24px; font-weight: bold;">7天 12小时</p>
</div>
</div>
</div>
`;
}
function showLogs() {
pageContent.innerHTML = `
<div style="padding: 30px;">
<h2 style="margin-bottom: 20px;">📝 系统日志</h2>
<div style="background: #1e1e1e; color: #00ff00; padding: 20px; border-radius: 8px; font-family: 'Courier New', monospace; font-size: 14px; max-height: 400px; overflow-y: auto;">
<div>[2024-01-16 10:30:15] INFO: 系统启动完成</div>
<div>[2024-01-16 10:30:16] INFO: 加载配置文件成功</div>
<div>[2024-01-16 10:30:17] INFO: 数据库连接建立</div>
<div>[2024-01-16 10:30:18] INFO: API 服务启动,端口: 8080</div>
<div>[2024-01-16 10:35:22] INFO: 用户登录: admin</div>
<div>[2024-01-16 10:36:45] INFO: 模型配置更新</div>
<div>[2024-01-16 10:38:12] WARN: 知识库索引队列较长</div>
<div>[2024-01-16 10:40:33] INFO: 知识库索引完成</div>
</div>
</div>
`;
}
function exportConfig() {
const config = {
timestamp: new Date().toISOString(),
version: "1.0.0",
settings: {
api: { endpoint: "https://api.example.com", timeout: 30000 },
models: ["gpt-4", "gpt-3.5-turbo"],
knowledge: { maxSize: "10GB", indexing: true }
}
};
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'coze-studio-config.json';
a.click();
URL.revokeObjectURL(url);
alert(t('export.success'));
}
function showHelp() {
pageContent.innerHTML = `
<div style="padding: 30px;">
<h2 style="margin-bottom: 20px;">❓ ${t('help.title')}</h2>
<div style="display: grid; gap: 20px;">
<div style="background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h3 style="color: #667eea; margin-top: 0;">${t('help.quickstart.title')}</h3>
<p>1. ${t('help.quickstart.step1')}</p>
<p>2. ${t('help.quickstart.step2')}</p>
<p>3. ${t('help.quickstart.step3')}</p>
<p>4. ${t('help.quickstart.step4')}</p>
</div>
<div style="background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h3 style="color: #667eea; margin-top: 0;">${t('help.features.title')}</h3>
<p><strong>${t('page.basic.title')}</strong>${t('help.features.basic')}</p>
<p><strong>${t('page.model.title')}</strong>${t('help.features.model')}</p>
<p><strong>${t('page.knowledge.title')}</strong>${t('help.features.kb')}</p>
</div>
<div style="background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h3 style="color: #667eea; margin-top: 0;">${t('help.support.title')}</h3>
<p>${t('help.support.contact')}</p>
<p>${t('help.support.email', { email: 'support@coze-studio.com' })}</p>
<p>${t('help.support.phone', { phone: '400-123-4567' })}</p>
</div>
</div>
</div>
`;
}
</script>
</body>
</html>