Files
coze-studio/backend/conf/admin/index.html

3468 lines
188 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">
<a href="#" class="nav-link" data-page="workspace">
<span class="nav-icon">🏠</span>
工作台
</a>
</li>
<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.workspace': '工作台',
'nav.config': '配置管理',
'nav.basic': '基础配置',
'nav.model': '模型管理',
'nav.knowledge': '知识库配置',
'page.workspace.title': '工作台',
'page.workspace.desc': '工作台概览和快速操作',
'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.workspace_forbidden': '无法访问工作台:',
'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': '管理员',
'workspace.hero.title': '欢迎使用 Coze Studio',
'workspace.greeting.late': '夜深了,注意休息 🌙',
'workspace.greeting.morning': '早上好 🌅',
'workspace.greeting.noon': '中午好 ☀️',
'workspace.greeting.afternoon': '下午好 🌤️',
'workspace.greeting.evening': '晚上好 🌆',
'workspace.card.basic.title': '基础配置',
'workspace.card.basic.desc': '配置系统基础参数和核心设置,确保系统正常运行。',
'workspace.card.basic.cta': '开始配置',
'workspace.card.model.title': '模型管理',
'workspace.card.model.desc': '管理AI模型配置、参数调优和模型版本控制。',
'workspace.card.model.cta': '管理模型',
'workspace.card.knowledge.title': '知识库配置',
'workspace.card.knowledge.desc': '配置知识库索引、向量存储和检索策略设置。',
'workspace.card.knowledge.cta': '配置知识库',
'workspace.footer.cta': '让我们开始构建您的智能助手吧!',
'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.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',
// 知识库配置
'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.workspace': 'Workspace',
'nav.config': 'Configuration',
'nav.basic': 'Basic Config',
'nav.model': 'Model Management',
'nav.knowledge': 'Knowledge Config',
'page.workspace.title': 'Workspace',
'page.workspace.desc': 'Workspace overview and quick actions',
'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.workspace_forbidden': 'Cannot access workspace: ',
'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',
'workspace.hero.title': 'Welcome to Coze Studio',
'workspace.greeting.late': 'Its late, rest well 🌙',
'workspace.greeting.morning': 'Good morning 🌅',
'workspace.greeting.noon': 'Good noon ☀️',
'workspace.greeting.afternoon': 'Good afternoon 🌤️',
'workspace.greeting.evening': 'Good evening 🌆',
'workspace.card.basic.title': 'Basic Config',
'workspace.card.basic.desc': 'Configure system parameters and core settings to ensure proper operation.',
'workspace.card.basic.cta': 'Start Configuring',
'workspace.card.model.title': 'Model Management',
'workspace.card.model.desc': 'Manage AI model configs, tuning, and versioning.',
'workspace.card.model.cta': 'Manage Models',
'workspace.card.knowledge.title': 'Knowledge Config',
'workspace.card.knowledge.desc': 'Configure indexing, vector storage, and retrieval strategies.',
'workspace.card.knowledge.cta': 'Configure Knowledge',
'workspace.footer.cta': 'Lets start building your AI assistant!',
'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',
'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.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',
// 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="workspace"]'), t('nav.workspace'));
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) || 'workspace';
const map = {
'workspace': { title: t('page.workspace.title'), desc: t('page.workspace.desc') },
'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 = {
'workspace': { title: t('page.workspace.title'), description: t('page.workspace.desc') },
'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 = {
'workspace': { title: t('page.workspace.title'), description: t('page.workspace.desc') },
'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 workspaceLink = document.querySelector('[data-page="workspace"]');
if (workspaceLink) {
// 移除所有活动状态
navLinks.forEach(l => l.classList.remove('active'));
submenuLinks.forEach(l => l.classList.remove('active'));
// 添加工作台的活动状态
workspaceLink.classList.add('active');
}
// 设置默认页面的URL hash
window.location.hash = 'workspace';
loadPageContent('workspace');
}
// 显示页面内容
function showPageContent(pageName, title) {
// 如果是工作台页面,先调用权限检查接口
if (pageName === 'workspace') {
fetch('/api/admin/config/basic/get')
.then(resp => resp.json())
.then(data => {
if (handleApiCode(data)) {
return;
}
if (Number(data?.code) === 0) {
showWorkspaceContent();
return;
}
showError(t('error.workspace_forbidden') + (data?.msg ? String(data.msg) : ''));
})
.catch(err => {
console.error('Workspace permission check failed:', err);
showError(t('error.workspace_forbidden') + (err?.message ? String(err.message) : ''));
});
return;
}
// 如果是基础配置页面加载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 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')}">
</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>
${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/Arkclass=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 字段(仅 OpenAIclass=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 的后端、项目、位置(仅 Geminiclass=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';
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;
}
// 构造 payloadBase URL 为空时不传该字段Ollama 不传 api_key
const baseConn = { model: model };
if (baseUrl) baseConn.base_url = baseUrl;
if (requireApiKey && apiKey) baseConn.api_key = apiKey;
// 读取“启用 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_request_dims: conn?.openai?.embedding_request_dims ?? conn?.openai?.request_dims ?? '',
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" 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" 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 style="${labelStyle}">Embedding Request Dims</div>
<input id="kb_openai_embedding_request_dims" type="number" style="${inputStyle}" value="${initial.openai_embedding_request_dims}" />
</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" 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" 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" 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: {
request_dims: toNum(val('kb_openai_embedding_request_dims')),
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();
if (typeVal === 0) {
// Ark: Base URL、API Key、Model、Dims 必填API Type 可选
const dimsVal = Number(document.getElementById('kb_ark_embedding_dims')?.value);
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 (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 = Number(document.getElementById('kb_openai_embedding_dims')?.value);
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 (missing.length > 0) {
showError(t('error.missing_required', { list: missing.join(', ') }));
return;
}
} else if (typeVal === 2) {
// Ollama: Base URL、Model、Dims 必填
const dimsVal = Number(document.getElementById('kb_ollama_embedding_dims')?.value);
const missing = [];
if (!get('kb_ollama_base_url')) missing.push('Base URL');
if (!get('kb_ollama_model')) missing.push('Model');
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 = Number(document.getElementById('kb_gemini_embedding_dims')?.value);
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 (missing.length > 0) {
showError(t('error.missing_required', { list: missing.join(', ') }));
return;
}
} else if (typeVal === 4) {
// HTTP: Address、Dims 必填
const dimsVal = Number(document.getElementById('kb_http_dims')?.value);
const missing = [];
if (!get('kb_http_address')) missing.push('Address');
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>