//! MNOTE AI 管理页面组件 //! //! 可供 /admin/ai 和 /user/ai 使用的 Leptos SSR 管理页面。 //! 包含 Overview、Models、Tools、Skills/MCP、Access Scopes 等面板。 //! 用户模式下隐藏管理写操作。写管理使用 GET/PUT /api/ai-admin/settings。 //! 模型/工具/技能面板通过 JS 渲染内联编辑表单,保存后 PUT 全量配置。 use leptos::prelude::*; /// AI 管理页面内嵌 scoped 样式 const AI_ADMIN_STYLE: &str = r#" .mnote-ai-admin { min-height: 100vh; background: #f5f7fa; color: #1f1f1f; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } .mnote-ai-admin-mobile-trigger { display: none; position: fixed; top: 16px; left: 16px; z-index: 1001; width: 34px; height: 34px; border: 1px solid #d9d9d9; border-radius: 6px; background: #ffffff; color: #1890ff; box-shadow: 0 2px 8px rgba(0,0,0,0.08); cursor: pointer; } .mnote-ai-admin-shell { display: grid; grid-template-columns: 200px minmax(0, 1fr); min-height: 100vh; } .mnote-ai-admin-sider { position: sticky; top: 0; height: 100vh; overflow: auto; background: #ffffff; border-right: 1px solid #f0f0f0; } .mnote-ai-admin-brand { padding: 20px 16px 12px; border-bottom: 1px solid #f0f0f0; } .mnote-ai-admin-brand-title { margin: 0; color: #1890ff; font-size: 16px; font-weight: 650; line-height: 1.2; } .mnote-ai-admin-brand-subtitle { display: block; margin-top: 3px; color: #8c8c8c; font-size: 11px; } .mnote-ai-admin-nav { padding: 8px 0; } .mnote-ai-admin-nav-title { padding: 10px 16px 6px; color: #bfbfbf; font-size: 11px; font-weight: 600; } .mnote-ai-admin-nav a { position: relative; display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 16px 0 24px; color: rgba(0,0,0,0.78); font-size: 14px; text-decoration: none; } .mnote-ai-admin-nav a::before { width: 16px; color: #8c8c8c; font-size: 15px; text-align: center; } .mnote-ai-admin-nav a[data-nav-icon="home"]::before { content: "⌂"; } .mnote-ai-admin-nav a[data-nav-icon="users"]::before { content: "👤"; } .mnote-ai-admin-nav a[data-nav-icon="models"]::before { content: "⚙"; } .mnote-ai-admin-nav a[data-nav-icon="service"]::before { content: "☁"; } .mnote-ai-admin-nav a[data-nav-icon="stats"]::before { content: "▦"; } .mnote-ai-admin-nav a[data-nav-icon="tools"]::before { content: "🛡"; } .mnote-ai-admin-nav a[data-nav-icon="skills"]::before { content: "⚡"; } .mnote-ai-admin-nav a[data-nav-icon="folder"]::before { content: "📁"; } .mnote-ai-admin-nav a[data-nav-icon="db"]::before { content: "▣"; } .mnote-ai-admin-nav a[data-nav-icon="channel"]::before { content: "◇"; } .mnote-ai-admin-nav a[data-nav-icon="monitor"]::before { content: "◉"; } .mnote-ai-admin-nav a:hover { color: #1890ff; background: #e6f7ff; } .mnote-ai-admin-nav a.is-active { color: #1890ff; background: #e6f7ff; font-weight: 500; } .mnote-ai-admin-nav a.is-active::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; width: 3px; background: #1890ff; } .mnote-ai-admin-sider-footer { position: sticky; bottom: 0; padding: 12px 16px; border-top: 1px solid #f0f0f0; background: #ffffff; } .mnote-ai-admin-content { min-width: 0; padding: 24px 32px; } .mnote-ai-admin-breadcrumb { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; color: rgba(0,0,0,0.45); font-size: 14px; } .mnote-ai-admin-breadcrumb a { color: rgba(0,0,0,0.45); text-decoration: none; } .mnote-ai-admin-breadcrumb a:hover { color: #1890ff; } .mnote-ai-admin-card-shell { border: 1px solid #f0f0f0; border-radius: 8px; background: #ffffff; box-shadow: 0 1px 2px rgba(0,0,0,0.02); } .mnote-ai-admin-header { padding: 24px 24px 0; margin-bottom: 0; } .mnote-ai-admin-header h1 { margin: 0 0 4px; color: rgba(0,0,0,0.88); font-size: 20px; font-weight: 650; line-height: 1.25; } .mnote-ai-admin-header p { margin: 0; color: rgba(0,0,0,0.45); font-size: 13px; } .mnote-ai-admin-section { display: none; padding: 24px; } .mnote-ai-admin-section.is-active { display: block; } .mnote-ai-admin-panel { display: none; } .mnote-ai-admin-panel.is-active { display: block; } .mnote-ai-admin-section-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; } .mnote-ai-admin-section-header h2 { margin: 0; color: rgba(0,0,0,0.88); font-size: 18px; font-weight: 650; line-height: 1.35; } .mnote-ai-admin-section-header .mnote-ai-admin-section-desc { margin: 4px 0 0; color: rgba(0,0,0,0.45); font-size: 13px; } .mnote-ai-admin-section-body { min-width: 0; } .mnote-ai-admin-grid, .mnote-ai-admin-subgrid, .mnote-ai-admin-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; } .mnote-ai-admin-card, .mnote-ai-admin-mini-panel, .mnote-ai-admin-stat-card { border: 1px solid #f0f0f0; border-radius: 8px; background: #ffffff; padding: 12px 16px; } .mnote-ai-admin-card { min-height: 78px; } .mnote-ai-admin-card-label, .mnote-ai-admin-mini-desc, .mnote-ai-admin-card-desc { color: rgba(0,0,0,0.45); font-size: 12px; } .mnote-ai-admin-card-value { display: block; margin: 6px 0 3px; color: rgba(0,0,0,0.88); font-size: 24px; font-weight: 650; line-height: 1.1; } .mnote-ai-admin-mini-title { margin: 0 0 6px; color: rgba(0,0,0,0.88); font-size: 14px; font-weight: 600; } .mnote-ai-admin-card-title-row, .mnote-ai-admin-card-meta-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; } .mnote-ai-admin-card-title-row { color: rgba(0,0,0,0.88); font-size: 14px; font-weight: 600; } .mnote-ai-admin-card-meta-row { margin: 8px 0 0; color: rgba(0,0,0,0.45); font-size: 12px; } .mnote-ai-admin-admin-list, .mnote-ai-admin-stack { display: grid; gap: 12px; } .mnote-ai-admin-admin-card { border: 1px solid #f0f0f0; border-radius: 8px; background: #ffffff; } .mnote-ai-admin-admin-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #f0f0f0; background: #fafafa; } .mnote-ai-admin-admin-card-body { padding: 14px 16px 16px; } .mnote-ai-admin-admin-card h3 { margin: 0; font-size: 14px; } .mnote-ai-admin-kv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; } .mnote-ai-admin-kv { border: 1px solid #f0f0f0; border-radius: 6px; padding: 9px 10px; background: #ffffff; } .mnote-ai-admin-kv span { display: block; color: rgba(0,0,0,0.45); font-size: 12px; } .mnote-ai-admin-kv strong { display: block; margin-top: 4px; color: rgba(0,0,0,0.88); font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } .mnote-ai-admin-table-wrap { width: 100%; overflow: auto; } .mnote-ai-admin-table { width: 100%; border-collapse: collapse; table-layout: auto; } .mnote-ai-admin-table th { padding: 12px 16px; border-bottom: 1px solid #f0f0f0; background: #fafafa; color: rgba(0,0,0,0.65); font-size: 13px; font-weight: 600; text-align: left; white-space: nowrap; } .mnote-ai-admin-table td { padding: 12px 16px; border-bottom: 1px solid #f0f0f0; color: rgba(0,0,0,0.78); font-size: 13px; vertical-align: middle; } .mnote-ai-admin-table tr:last-child td { border-bottom: 0; } .mnote-ai-admin-table tr:hover td { background: #fafafa; } .mnote-ai-admin-badge, .mnote-ai-admin-tag { display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fafafa; color: rgba(0,0,0,0.65); font-size: 12px; line-height: 20px; white-space: nowrap; } .mnote-ai-admin-badge--active, .mnote-ai-admin-tag--green { border-color: #b7eb8f; background: #f6ffed; color: #389e0d; } .mnote-ai-admin-badge--default, .mnote-ai-admin-tag--blue { border-color: #91caff; background: #e6f4ff; color: #1677ff; } .mnote-ai-admin-tag--red { border-color: #ffa39e; background: #fff1f0; color: #cf1322; } .mnote-ai-admin-tag--orange { border-color: #ffd591; background: #fff7e6; color: #d46b08; } .mnote-ai-admin-empty, .mnote-ai-admin-placeholder, .mnote-ai-admin-skeleton { padding: 28px 0; color: rgba(0,0,0,0.45); font-size: 13px; text-align: center; } .mnote-ai-admin-json { max-height: 300px; margin-top: 8px; overflow: auto; border: 1px solid #f0f0f0; border-radius: 6px; background: #fafafa; padding: 12px; color: rgba(0,0,0,0.72); font: 12px/1.55 "JetBrains Mono", "SFMono-Regular", Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } .mnote-ai-admin-link, .mnote-ai-admin-policy-link { color: #1677ff; text-decoration: none; } .mnote-ai-admin-policy-link, .mnote-ai-admin-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 12px; border: 1px solid #d9d9d9; border-radius: 6px; background: #ffffff; color: rgba(0,0,0,0.88); font-size: 14px; font-weight: 400; cursor: pointer; transition: border-color 0.15s, color 0.15s, background 0.15s; } .mnote-ai-admin-policy-link:hover, .mnote-ai-admin-btn:hover { border-color: #4096ff; color: #1677ff; } .mnote-ai-admin-btn--primary { border-color: #1677ff; background: #1677ff; color: #ffffff; } .mnote-ai-admin-btn--primary:hover { border-color: #4096ff; background: #4096ff; color: #ffffff; } .mnote-ai-admin-btn--danger { color: #cf1322; } .mnote-ai-admin-btn--small { height: 24px; padding: 0 7px; font-size: 12px; } .mnote-ai-admin-icon-actions { display: inline-flex; align-items: center; gap: 4px; } .mnote-ai-admin-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 4px; background: transparent; color: rgba(0,0,0,0.55); cursor: pointer; } .mnote-ai-admin-icon-btn:hover { background: #e6f4ff; color: #1677ff; } .mnote-ai-admin-icon-btn.is-danger:hover { background: #fff1f0; color: #cf1322; } .mnote-ai-admin-input, .mnote-ai-admin-select { width: 100%; min-height: 32px; box-sizing: border-box; border: 1px solid #d9d9d9; border-radius: 6px; background: #ffffff; color: rgba(0,0,0,0.88); font-size: 14px; } .mnote-ai-admin-input { padding: 4px 11px; } .mnote-ai-admin-select { padding: 4px 8px; } .mnote-ai-admin-input:focus, .mnote-ai-admin-select:focus { outline: none; border-color: #4096ff; box-shadow: 0 0 0 2px rgba(5,145,255,0.1); } .mnote-ai-admin-input:disabled, .mnote-ai-admin-select:disabled { background: #f5f5f5; color: rgba(0,0,0,0.25); cursor: not-allowed; } .mnote-ai-admin-label { display: block; margin-bottom: 5px; color: rgba(0,0,0,0.65); font-size: 13px; } .mnote-ai-admin-form-group { margin-bottom: 12px; } .mnote-ai-admin-form-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .mnote-ai-admin-actions-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .mnote-ai-admin-search-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } .mnote-ai-admin-search-row .mnote-ai-admin-input { max-width: 260px; } .mnote-ai-admin-provider-card { margin-bottom: 12px; border: 1px solid #f0f0f0; border-radius: 8px; background: #ffffff; } .mnote-ai-admin-provider-card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #f0f0f0; background: #fafafa; } .mnote-ai-admin-provider-card-title { font-size: 14px; font-weight: 600; } .mnote-ai-admin-provider-card-body { padding: 16px; } .mnote-ai-admin-collapse { margin-bottom: 12px; border: 1px solid #f0f0f0; border-radius: 8px; background: #ffffff; overflow: hidden; } .mnote-ai-admin-collapse > summary { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: #fafafa; cursor: pointer; font-weight: 600; } .mnote-ai-admin-collapse-body { padding: 12px 16px; } .mnote-ai-admin-save-status { display: none; margin-bottom: 12px; padding: 9px 12px; border-radius: 6px; font-size: 13px; } .mnote-ai-admin-save-status--success { display: block; border: 1px solid #b7eb8f; background: #f6ffed; color: #389e0d; } .mnote-ai-admin-save-status--error { display: block; border: 1px solid #ffa39e; background: #fff1f0; color: #cf1322; } .mnote-ai-admin-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; } .mnote-ai-admin-toggle input { opacity: 0; width: 0; height: 0; } .mnote-ai-admin-toggle-slider { position: absolute; inset: 0; border-radius: 20px; background: #d9d9d9; cursor: pointer; transition: background 0.2s; } .mnote-ai-admin-toggle-slider::before { content: ""; position: absolute; left: 2px; bottom: 2px; width: 16px; height: 16px; border-radius: 50%; background: #ffffff; transition: transform 0.2s; } .mnote-ai-admin-toggle input:checked + .mnote-ai-admin-toggle-slider { background: #1677ff; } .mnote-ai-admin-toggle input:checked + .mnote-ai-admin-toggle-slider::before { transform: translateX(16px); } .mnote-ai-admin-secret-hint { margin-top: 3px; color: rgba(0,0,0,0.35); font-size: 12px; } .mnote-ai-admin-skill-row, .mnote-ai-admin-user-option, .mnote-ai-admin-user-policy-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .mnote-ai-admin-skill-row { padding: 10px 0; border-bottom: 1px solid #f0f0f0; } .mnote-ai-admin-skill-row:last-child { border-bottom: 0; } .mnote-ai-admin-skill-info { min-width: 0; flex: 1; } .mnote-ai-admin-user-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; margin-bottom: 10px; } .mnote-ai-admin-user-editor-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; } .mnote-ai-admin-user-editor-head h3 { margin: 0; font-size: 16px; } .mnote-ai-admin-user-editor-head p { margin: 4px 0 0; color: rgba(0,0,0,0.45); font-size: 12px; } .mnote-ai-admin-user-tabbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; } .mnote-ai-admin-user-option { justify-content: flex-start; border: 1px solid #f0f0f0; border-radius: 6px; padding: 8px 10px; } .mnote-ai-admin-user-option span, .mnote-ai-admin-user-policy-row > span { display: grid; gap: 2px; min-width: 0; } .mnote-ai-admin-user-option small, .mnote-ai-admin-user-policy-row small { color: rgba(0,0,0,0.45); font-size: 12px; } .mnote-ai-admin-user-section { padding: 14px 0; border-bottom: 1px solid #f0f0f0; } .mnote-ai-admin-user-section h3 { margin: 0 0 10px; font-size: 14px; } .mnote-ai-admin-user-section-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; } .mnote-ai-admin-user-section-title h3 { margin: 0; } .mnote-ai-admin-permission-group { margin-bottom: 14px; border: 1px solid #f0f0f0; border-radius: 8px; background: #ffffff; overflow: hidden; } .mnote-ai-admin-permission-group-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; } .mnote-ai-admin-permission-group-head strong { font-size: 13px; } .mnote-ai-admin-permission-group-body { padding: 8px 12px; } .mnote-ai-admin-permission-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid #fafafa; } .mnote-ai-admin-permission-row:last-child { border-bottom: 0; } .mnote-ai-admin-permission-row-main { min-width: 0; flex: 1; } .mnote-ai-admin-permission-row-main strong { display: block; font-size: 13px; overflow-wrap: anywhere; } .mnote-ai-admin-permission-row-main small { color: rgba(0,0,0,0.45); font-size: 12px; } .mnote-ai-admin-permission-row-actions { display: inline-flex; align-items: center; gap: 8px; flex-shrink: 0; } .mnote-ai-admin-subsection-card { margin-bottom: 14px; border: 1px solid #f0f0f0; border-radius: 8px; background: #ffffff; } .mnote-ai-admin-subsection-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #f0f0f0; background: #fafafa; } .mnote-ai-admin-subsection-card-head h3 { margin: 0; font-size: 14px; } .mnote-ai-admin-subsection-card-body { padding: 14px 16px 16px; } .mnote-ai-admin-access-scope-row { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid #f0f0f0; } .mnote-ai-admin-access-scope-path { color: rgba(0,0,0,0.88); font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } .mnote-ai-admin-access-scope-meta { color: rgba(0,0,0,0.45); font-size: 12px; } .mnote-ai-admin-drawer { position: fixed; inset: 0; z-index: 1100; display: none; } .mnote-ai-admin-drawer.is-open { display: block; } .mnote-ai-admin-drawer-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.35); } .mnote-ai-admin-drawer-panel { position: absolute; top: 0; right: 0; width: min(720px, 100vw); height: 100%; overflow: auto; background: #ffffff; box-shadow: -4px 0 16px rgba(0,0,0,0.12); } .mnote-ai-admin-drawer-head { position: sticky; top: 0; z-index: 1; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid #f0f0f0; background: #ffffff; } .mnote-ai-admin-drawer-head h3 { margin: 0; font-size: 16px; } .mnote-ai-admin-drawer-body { padding: 18px 20px 24px; } @media (max-width: 768px) { .mnote-ai-admin-mobile-trigger { display: inline-flex; align-items: center; justify-content: center; } .mnote-ai-admin-shell { grid-template-columns: 1fr; } .mnote-ai-admin-sider { position: fixed; inset: 0 auto 0 0; z-index: 1000; width: 220px; transform: translateX(-100%); transition: transform 0.18s ease; box-shadow: 2px 0 12px rgba(0,0,0,0.12); } .mnote-ai-admin.is-mobile-menu-open .mnote-ai-admin-sider { transform: translateX(0); } .mnote-ai-admin-content { padding: 16px; padding-top: 58px; } .mnote-ai-admin-section, .mnote-ai-admin-header { padding-left: 16px; padding-right: 16px; } .mnote-ai-admin-section-header, .mnote-ai-admin-search-row, .mnote-ai-admin-access-scope-row { flex-direction: column; align-items: flex-start; } .mnote-ai-admin-form-row { grid-template-columns: 1fr; } } "#; /// AI 管理页面内嵌脚本 /// /// 从后端 API 获取 effective providers/models、access scopes 和 config, /// 填充到页面相应区域。模型/工具/技能 MCP 面板通过 loadConfig/saveConfig 提供内联编辑。 const AI_ADMIN_SCRIPT: &str = r#" (function () { function initAiAdminPage(scope) { var root = (scope || document).querySelector('[data-testid="mnote-ai-admin-page"]'); if (!root) return; if (root.getAttribute('data-ai-admin-ready') === 'true') return; root.setAttribute('data-ai-admin-ready', 'true'); var pageConfig = (function () { var node = root.querySelector('#__MNOTE_AI_ADMIN_CONFIG__'); try { return JSON.parse(node ? node.textContent || '{}' : '{}'); } catch (_) { return {}; } })(); var isAdmin = pageConfig.isAdmin === true; var overviewProvidersEl = root.querySelector('[data-ai-admin-overview-providers]'); var overviewModelsEl = root.querySelector('[data-ai-admin-overview-models]'); var overviewScopesEl = root.querySelector('[data-ai-admin-overview-scopes]'); var providerTableBody = root.querySelector('[data-ai-admin-providers-body]'); var modelPolicyBody = root.querySelector('[data-ai-admin-model-policy-body]'); var toolPolicyBody = root.querySelector('[data-ai-admin-tool-policy-body]'); var usageSummary = root.querySelector('[data-ai-admin-usage-summary]'); var healthGrid = root.querySelector('[data-ai-admin-health-grid]'); var servicePanel = root.querySelector('[data-ai-admin-service-panel]'); var channelsPanel = root.querySelector('[data-ai-admin-channels-panel]'); var knowledgePanel = root.querySelector('[data-ai-admin-knowledge-panel]'); var accessScopesList = root.querySelector('[data-ai-admin-access-scopes-list]'); var accessScopesJson = root.querySelector('[data-ai-admin-access-scopes-json]'); var accessScopesMessage = root.querySelector('[data-ai-admin-access-scopes-message]'); var effectiveJson = root.querySelector('[data-ai-admin-effective-json]'); var sessionsBody = root.querySelector('[data-ai-admin-sessions-body]'); var receiptsBody = root.querySelector('[data-ai-admin-receipts-body]'); var receiptsSummary = root.querySelector('[data-ai-admin-receipts-summary]'); // ── Config 面板容器 ── var modelsContainer = root.querySelector('[data-ai-admin-models-container]'); var toolsContainer = root.querySelector('[data-ai-admin-tools-container]'); var skillsContainer = root.querySelector('[data-ai-admin-skills-container]'); var mcpContainer = root.querySelector('[data-ai-admin-mcp-container]'); var modelsSaveStatus = root.querySelector('[data-ai-admin-models-save-status]'); var toolsSaveStatus = root.querySelector('[data-ai-admin-tools-save-status]'); var skillsSaveStatus = root.querySelector('[data-ai-admin-skills-save-status]'); var usersList = root.querySelector('[data-ai-admin-users-list]'); var userSettingsEditor = root.querySelector('[data-ai-admin-user-settings]'); var userSettingsStatus = root.querySelector('[data-ai-admin-user-save-status]'); var mobileTrigger = root.querySelector('[data-action="toggle-mobile-menu"]'); var drawer = root.querySelector('[data-ai-admin-user-drawer]'); var drawerTitle = root.querySelector('[data-ai-admin-user-drawer-title]'); var drawerSubtitle = root.querySelector('[data-ai-admin-user-drawer-subtitle]'); var selectedUserId = ''; var selectedUserSettings = null; var selectedUserPanel = 'models'; function setActivePanel(panelId, replaceHash) { var targetId = panelId || (location.hash || '').replace(/^#/, '') || 'ai-admin-overview'; var panels = root.querySelectorAll('[data-ai-admin-panel]'); var found = false; panels.forEach(function (panel) { var active = panel.id === targetId; panel.classList.toggle('is-active', active); panel.hidden = !active; if (active) found = true; }); if (!found && targetId !== 'ai-admin-overview') { setActivePanel('ai-admin-overview', true); return; } root.querySelectorAll('[data-ai-admin-nav]').forEach(function (item) { var active = item.getAttribute('href') === '#' + targetId; item.classList.toggle('is-active', active); if (active) item.setAttribute('aria-current', 'page'); else item.removeAttribute('aria-current'); }); root.classList.remove('is-mobile-menu-open'); if (replaceHash && history.replaceState) { history.replaceState(null, '', '#' + targetId); } } root.querySelectorAll('[data-ai-admin-nav]').forEach(function (item) { item.addEventListener('click', function (event) { event.preventDefault(); setActivePanel((item.getAttribute('href') || '').replace(/^#/, ''), true); }); }); window.addEventListener('hashchange', function () { setActivePanel(); }); setActivePanel(); if (mobileTrigger) { mobileTrigger.addEventListener('click', function () { root.classList.toggle('is-mobile-menu-open'); }); } root.addEventListener('click', function (event) { var closeTarget = event.target && event.target.closest ? event.target.closest('[data-action="close-user-drawer"], [data-ai-admin-drawer-backdrop]') : null; if (closeTarget && drawer) drawer.classList.remove('is-open'); }); function setText(node, value) { if (!node) return; node.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2); } function escapeHtml(value) { return String(value == null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } async function requestJson(url, options) { var response = await fetch(url, { credentials: 'include', headers: { 'content-type': 'application/json' }, ...options, }); var payload = await response.json().catch(function () { return {}; }); if (!response.ok && !payload.ok) { throw new Error((payload && payload.message) || ('请求失败: ' + response.status)); } return payload; } function renderBadge(text, className) { return '' + escapeHtml(text) + ''; } function renderTag(text, tone) { return '' + escapeHtml(text) + ''; } function renderKv(label, value) { return '
' + escapeHtml(label) + '' + escapeHtml(value == null || value === '' ? '-' : value) + '
'; } function actionLabel(action) { if (action === 'deny') return '拒绝'; if (action === 'ask') return '审批'; return '允许'; } function actionTone(action) { if (action === 'deny') return 'red'; if (action === 'ask') return 'orange'; return 'green'; } function groupBy(items, keyFn) { return (items || []).reduce(function(acc, item) { var key = keyFn(item) || '其他'; if (!acc[key]) acc[key] = []; acc[key].push(item); return acc; }, {}); } function renderPermissionGroup(title, countLabel, bodyHtml) { return '
' + '
' + escapeHtml(title) + '' + renderTag(countLabel, 'blue') + '
' + '
' + (bodyHtml || '
暂无数据
') + '
' + '
'; } function renderSelectablePermissionRow(options) { var select = options.selectHtml || ''; return '
' + '
' + escapeHtml(options.title || '-') + '' + escapeHtml(options.subtitle || '') + '
' + '
' + (options.metaHtml || '') + select + (options.resetHtml || '') + '
' + '
'; } function normalizeModelRef(provider, modelId) { var p = provider || 'omniroute'; var m = modelId || 'freefirst'; if (String(m).indexOf(p + '/') === 0) return String(m); return p + '/' + m; } function renderServicePanels(piStatus, effectivePayload) { var providers = effectivePayload && Array.isArray(effectivePayload.providers) ? effectivePayload.providers : []; var openhubStatus = '默认入口保留'; var defaultModel = effectivePayload && (effectivePayload.defaultModel || effectivePayload.default_model || ''); var piDefaultModel = piStatus ? normalizeModelRef(piStatus.defaultModelProvider || piStatus.default_model_provider, piStatus.defaultModelId || piStatus.default_model_id) : 'omniroute/freefirst'; var piRuntime = piStatus ? (piStatus.runtimeMode || piStatus.runtime_mode || '-') : '-'; var piRunning = piStatus && piStatus.running === true; if (servicePanel) { servicePanel.innerHTML = '
' + '
' + '

OpenHub

' + renderTag(openhubStatus, 'green') + '打开 OpenHub Admin' + '
' + '
' + '
' + renderKv('定位', '默认 Page AI 主线') + renderKv('Provider/模型真相', 'OpenHub 保持独立,不被 Pi Lab 替换') + renderKv('MNote 侧入口', '/page-ai/openhub/ai') + renderKv('管理入口', '/page-ai/openhub/admin') + '
' + '
' + '
' + '
' + '

Pi Lab

' + renderTag(piRunning ? '运行中' : '待启动', piRunning ? 'green' : 'orange') + renderTag(piRuntime, 'blue') + '
' + '
' + '
' + renderKv('默认模型', piDefaultModel) + renderKv('UI 模式', piStatus && piStatus.uiMode ? piStatus.uiMode : 'independent_mnote_native_drawer') + renderKv('Session', piStatus && piStatus.sessionId ? piStatus.sessionId : 'none') + renderKv('Provider Session', piStatus && piStatus.providerSessionId ? piStatus.providerSessionId : 'none') + renderKv('Runtime PID', piStatus && piStatus.pid ? String(piStatus.pid) : 'none') + renderKv('禁用 Pi 原始工具', (piStatus && piStatus.disabledPiBuiltinTools || ['bash','read','write','edit']).join(', ')) + renderKv('Receipt storage', piStatus && piStatus.receiptStorage ? piStatus.receiptStorage : 'control-plane / provider-neutral adapter') + renderKv('Session dir policy', piStatus && piStatus.managedPiSessionDirPolicy ? piStatus.managedPiSessionDirPolicy : '/.mnote/ai/pi-sessions//') + '
' + '
' + '
' + '
'; } if (channelsPanel) { var providerCards = providers.length ? providers.map(function(p) { var model = p.defaultModel || p.default_model || p.model || defaultModel || 'omniroute/freefirst'; return '
' + '

' + escapeHtml(p.name || p.id || p.provider || 'Provider') + '

' + renderTag(p.enabled !== false ? '启用' : '停用', p.enabled !== false ? 'green' : 'orange') + renderTag(p.id || p.provider || 'provider', 'blue') + '
' + '
' + renderKv('默认模型', model) + renderKv('Base URL', p.baseUrl || p.base_url || p.endpoint || '-') + renderKv('Secret 引用', p.secretRef || p.secret_ref || 'env://OMNIROUTE_API_KEY') + renderKv('密钥策略', '只保存 env:// 或 secret:// 引用,raw key 不进前端') + '
' + '
'; }).join('') : '
暂无 Provider 配置
'; channelsPanel.innerHTML = '
' + '
' + '

Omniroute

' + renderTag('默认渠道', 'green') + renderTag('Pi: ' + piDefaultModel, 'blue') + '
' + '
' + renderKv('Pi 默认模型', piDefaultModel) + renderKv('Pi Base URL', piStatus && piStatus.omnirouteBaseUrl ? piStatus.omnirouteBaseUrl : 'http://127.0.0.1:20128/v1') + renderKv('API Key', 'env://OMNIROUTE_API_KEY / 本机配置读取') + renderKv('Secret 策略', 'API key 与 secret 不进前端、不硬编码') + renderKv('作用域', 'MNote provider/model policy + Pi session config') + '
' + '
' + providerCards + '
'; } if (knowledgePanel) { var lightrag = effectivePayload && (effectivePayload.lightragProvider || effectivePayload.lightrag_provider) || {}; knowledgePanel.innerHTML = '
' + '
' + '

LightRAG

' + renderTag('唯一默认 provider', 'green') + renderTag('MNote facade', 'blue') + '
' + '
' + '
' + renderKv('Provider', lightrag.provider || 'lightrag') + renderKv('查询入口', 'mnote.knowledge_rag.query') + renderKv('引用回跳', 'citation / open-reference') + renderKv('Pi 接入方式', '只能通过 MNote knowledge facade 查询') + '
' + '
' + '
' + '
' + '

知识源

' + renderTag('复用 allowed roots', 'green') + renderTag('不引入第二套 RAG', 'orange') + '
' + '
' + '

资料 source/index/status 后续接 /api/knowledge-rag/*;目录授权继续来自 MNote allowed roots,不在 AI 管理页维护第二套目录。

' + '
' + '
' + '
'; } if (healthGrid) { healthGrid.innerHTML = [ ['OpenHub 默认入口', '保留', 'Page AI 主线未被 Pi Lab 替换'], ['Pi Lab', piRunning ? '运行中' : '可启动', 'runtime=' + piRuntime + ' · model=' + piDefaultModel], ['LightRAG', '默认', '唯一默认 knowledge provider,Pi 通过 MNote facade 查询'], ['Control-plane', effectivePayload && (effectivePayload.sourceOfTruth || effectivePayload.source_of_truth) || 'directory_grants', '授权、策略、receipt 真相层'] ].map(function(item) { return '
' + escapeHtml(item[0]) + '
' + renderBadge(item[1], 'mnote-ai-admin-badge--active') + '

' + escapeHtml(item[2]) + '

'; }).join(''); } } function openUserDrawer(panel) { selectedUserPanel = panel || selectedUserPanel || 'models'; if (drawer) drawer.classList.add('is-open'); if (selectedUserSettings) renderUserSettings(selectedUserSettings); } function showSaveStatus(el, type, msg) { if (!el) return; el.className = 'mnote-ai-admin-save-status mnote-ai-admin-save-status--' + type; el.textContent = msg; el.style.display = 'block'; if (type === 'success') { setTimeout(function () { el.style.display = 'none'; }, 4000); } } function renderUserList(users) { if (!usersList) return; if (!users || !users.length) { usersList.innerHTML = '
暂无用户
'; return; } var rows = users.map(function(user, idx) { var username = user.display_name || user.username || user.id; var role = user.role || (user.isAdmin ? 'admin' : 'user'); var status = user.disabled ? '禁用' : '启用'; var statusTone = user.disabled ? 'red' : 'green'; return '' + '' + escapeHtml(String(idx + 1)) + '' + '👤 ' + escapeHtml(username) + '
' + escapeHtml(user.email || user.id) + '
' + '' + renderTag(role === 'admin' ? '管理员' : '普通', role === 'admin' ? 'blue' : '') + '' + '' + renderTag(status, statusTone) + '' + '' + escapeHtml(user.workspace_path || user.workspacePath || '未初始化') + '' + '
' + '' + '' + '' + '' + '' + '' + '
' + ''; }).join(''); usersList.innerHTML = '
' + '' + '' + rows + '
ID用户名角色状态工作空间操作
'; usersList.querySelectorAll('[data-user-action]').forEach(function(button) { button.addEventListener('click', function() { selectedUserPanel = button.getAttribute('data-user-action') || 'models'; loadUserSettings(button.getAttribute('data-user-id') || '').then(function() { openUserDrawer(selectedUserPanel); }); }); }); } function renderUserSettings(payload) { if (!userSettingsEditor) return; selectedUserSettings = payload; if (drawerTitle) drawerTitle.textContent = payload.userId || '用户配置'; if (drawerSubtitle) drawerSubtitle.textContent = '逐用户模型 / 工具 / Skills / MCP / 目录权限'; if (payload.isGlobalPolicyOwner) { userSettingsEditor.innerHTML = '
该用户承载全局 AI 策略。请使用左侧“模型配置”“工具权限”“技能 / MCP”页面修改默认策略。
'; return; } var catalogModels = payload.catalogModels || []; var allowedIds = new Set((payload.allowedModels || []).map(function(model) { return model.id; })); var modelGroups = groupBy(catalogModels, function(model) { return model.providerName || model.provider || (model.id || '').split('/')[0] || 'Provider'; }); var modelRows = Object.keys(modelGroups).map(function(providerName) { var models = modelGroups[providerName] || []; var enabledCount = models.filter(function(model) { return allowedIds.has(model.id); }).length; var rows = models.map(function(model) { var usage = model.currentUsage || model.current_usage || 0; var limit = model.monthlyLimit || model.monthly_limit || 0; var usageText = usage ? ('已用 ' + usage + (limit ? (' / ' + limit) : '')) : (limit ? ('月限 ' + limit) : '未设置月限'); return renderSelectablePermissionRow({ title: model.name || model.id, subtitle: model.id || '', metaHtml: '' + escapeHtml(usageText) + '', selectHtml: '' }); }).join(''); return renderPermissionGroup(providerName, enabledCount + '/' + models.length + ' 已启用', rows); }).join(''); var toolGroups = groupBy(payload.tools || [], function(tool) { return tool.riskLevel || tool.risk_level || tool.risk || 'safe'; }); var riskLabels = { dangerous: '危险工具', moderate: '敏感工具', safe: '安全工具', custom: '自定义工具' }; var tools = ['dangerous', 'moderate', 'safe', 'custom'].map(function(risk) { var items = toolGroups[risk] || []; if (!items.length) return ''; var rows = items.map(function(tool) { var maxRank = tool.globalAction === 'allow' ? 2 : (tool.globalAction === 'ask' ? 1 : 0); var current = tool.action || tool.userAction || tool.globalAction || 'allow'; var selectHtml = ''; return renderSelectablePermissionRow({ title: tool.name, subtitle: tool.description || ('全局 ' + actionLabel(tool.globalAction || 'allow')), metaHtml: renderTag(actionLabel(current), actionTone(current)), selectHtml: selectHtml, resetHtml: tool.hasOverride ? '' : '' }); }).join(''); return renderPermissionGroup(riskLabels[risk] || risk, items.length + ' 项', rows); }).join(''); if (!tools && (payload.tools || []).length) { tools = (payload.tools || []).map(function(tool) { var maxRank = tool.globalAction === 'allow' ? 2 : (tool.globalAction === 'ask' ? 1 : 0); return '
' + escapeHtml(tool.name) + '全局 ' + escapeHtml(actionLabel(tool.globalAction)) + '' + '
'; }).join(''); } var enabledSkills = (payload.skills || []).filter(function(skill) { return skill.globallyEnabled !== false; }); var skills = (payload.skills || []).map(function(skill) { return renderSelectablePermissionRow({ title: skill.name || skill.id, subtitle: skill.description || (skill.globallyEnabled ? '全局启用' : '全局禁用'), metaHtml: renderTag(skill.enabled ? '启用' : '禁用', skill.enabled ? 'green' : 'red'), selectHtml: '', resetHtml: skill.hasOverride ? '' : '' }); }).join(''); if (skills) skills = renderPermissionGroup('Skills', enabledSkills.length + '/' + (payload.skills || []).length + ' 全局可用', skills); var mcps = (payload.mcpServers || []).map(function(server) { return renderSelectablePermissionRow({ title: server.name || server.id, subtitle: (server.transport || 'stdio') + ' · facade/sandbox · ' + (server.networkPolicy || 'deny-all'), metaHtml: renderTag(server.enabled ? '启用' : '禁用', server.enabled ? 'green' : 'red'), selectHtml: '', resetHtml: server.hasOverride ? '' : '' }); }).join(''); if (mcps) mcps = renderPermissionGroup('MCP Servers', (payload.mcpServers || []).length + ' 项', mcps); var roots = (payload.allowedRoots || payload.accessScopes || payload.roots || []).map(function(rootItem) { return '
' + escapeHtml(rootItem.rootPath || rootItem.path || rootItem.rootUri || '-') + '
' + '
' + escapeHtml(rootItem.source || rootItem.scope || 'directory_grants') + '
' + renderTag(rootItem.permission === 'write' ? '读写' : '只读', rootItem.permission === 'write' ? 'green' : '') + '
'; }).join(''); var activePanel = selectedUserPanel || 'models'; var header = '
' + '' + '' + '' + '' + '' + '' + '
'; var body = ''; if (activePanel === 'tools') { body = '

工具权限

按 OpenHub risk_level 分组
' + (tools || '
暂无工具
') + '
'; } else if (activePanel === 'skills') { body = '

Skills

全局启用后用户可单独降权
' + (skills || '
暂无 Skill
') + '
'; } else if (activePanel === 'mcp') { body = '

MCP

只允许 MNote facade/sandbox MCP
' + (mcps || '
暂无 MCP
') + '
'; } else if (activePanel === 'roots') { body = '

目录权限

统一授权管理
' + (roots || '
目录权限由统一 allowed roots 管理。请到目录权限页调整。
') + '
'; } else { body = '

模型权限

按 provider 分组,参考 OpenHub 用户模型权限
' + (modelRows || '
暂无可用模型
') + '
'; } userSettingsEditor.innerHTML = '

' + escapeHtml(payload.userId) + '

逐用户配置只能在管理员全局范围内降权或选择;目录权限复用 MNote allowed roots。

' + '' + renderTag(payload.defaultModel || '未设默认模型', 'blue') + renderTag((payload.allowedModels || []).length + ' 个模型', 'green') + '
' + header + body; userSettingsEditor.querySelectorAll('[data-user-tab]').forEach(function(btn) { btn.addEventListener('click', function() { selectedUserPanel = btn.getAttribute('data-user-tab') || 'models'; renderUserSettings(payload); }); }); var saveButton = userSettingsEditor.querySelector('[data-action="save-user-settings"]'); if (saveButton) saveButton.addEventListener('click', saveUserSettings); } async function loadUserSettings(userId) { if (!userId) return; selectedUserId = userId; var usersPayload = await requestJson('/api/ai-admin/users', { method: 'GET' }); renderUserList(usersPayload.users || []); var payload = await requestJson('/api/ai-admin/users/' + encodeURIComponent(userId) + '/settings', { method: 'GET' }); renderUserSettings(payload); } async function loadUsers() { if (!isAdmin || !usersList) return; var payload = await requestJson('/api/ai-admin/users', { method: 'GET' }); var users = payload.users || []; if (!selectedUserId && users.length) selectedUserId = users[0].id; renderUserList(users); if (selectedUserId) { var settings = await requestJson('/api/ai-admin/users/' + encodeURIComponent(selectedUserId) + '/settings', { method: 'GET' }); renderUserSettings(settings); } } async function saveUserSettings() { if (!selectedUserId || !userSettingsEditor) return; var modelInputs = userSettingsEditor.querySelectorAll('[data-user-model]'); var allowedModels = selectedUserSettings && selectedUserSettings.allowedModels ? selectedUserSettings.allowedModels.map(function(model) { return model.id; }) : []; if (modelInputs.length) { allowedModels = Array.from(userSettingsEditor.querySelectorAll('[data-user-model]:checked')).map(function(input) { return input.getAttribute('data-user-model'); }); } var defaultSelect = userSettingsEditor.querySelector('[data-user-default-model]'); var tools = {}; (selectedUserSettings && selectedUserSettings.tools || []).forEach(function(tool) { tools[tool.name] = tool.action; }); userSettingsEditor.querySelectorAll('[data-user-tool]').forEach(function(select) { tools[select.getAttribute('data-user-tool')] = select.value; }); var skills = {}; (selectedUserSettings && selectedUserSettings.skills || []).forEach(function(skill) { skills[skill.id] = skill.enabled; }); userSettingsEditor.querySelectorAll('[data-user-skill]').forEach(function(input) { skills[input.getAttribute('data-user-skill')] = input.checked; }); var mcpServers = {}; (selectedUserSettings && selectedUserSettings.mcpServers || []).forEach(function(server) { mcpServers[server.id] = server.enabled; }); userSettingsEditor.querySelectorAll('[data-user-mcp]').forEach(function(input) { mcpServers[input.getAttribute('data-user-mcp')] = input.checked; }); try { await requestJson('/api/ai-admin/users/' + encodeURIComponent(selectedUserId) + '/settings', { method: 'PUT', body: JSON.stringify({ allowedModels: allowedModels, defaultModel: defaultSelect ? defaultSelect.value : ((selectedUserSettings && selectedUserSettings.defaultModel) || ''), tools: tools, skills: skills, mcpServers: mcpServers }) }); showSaveStatus(userSettingsStatus, 'success', '用户 AI 配置已保存'); await loadUserSettings(selectedUserId); } catch (err) { showSaveStatus(userSettingsStatus, 'error', '保存失败: ' + (err.message || '未知错误')); } } // ── 1. 加载 effective providers/models ── async function loadEffective() { var payload; try { payload = await requestJson('/api/ai-admin/effective', { method: 'GET' }); } catch (_err) { try { payload = await requestJson('/api/ai-settings/effective', { method: 'GET' }); } catch (err) { setText(effectiveJson, { error: err.message || '无法加载 effective 配置', note: 'API 端点暂未实现,此为占位' }); if (overviewProvidersEl) overviewProvidersEl.textContent = '-'; if (overviewModelsEl) overviewModelsEl.textContent = '-'; if (providerTableBody) providerTableBody.innerHTML = '暂无数据'; return; } } setText(effectiveJson, payload); var providers = payload && (payload.providers || []); var models = payload && (payload.models || []); var defaultModel = payload && (payload.defaultModel || payload.default_model || ''); var modelCount = Array.isArray(models) ? models.length : 0; if (providerTableBody && Array.isArray(providers)) { if (providers.length === 0) { providerTableBody.innerHTML = '暂无配置'; } else { providerTableBody.innerHTML = providers.map(function(p) { var name = escapeHtml(p.name || p.provider || p.id || '未知'); var model = escapeHtml(p.model || p.defaultModel || p.default_model || defaultModel || '默认'); var status = p.enabled !== false ? renderBadge('启用', 'mnote-ai-admin-badge--active') : renderBadge('停用'); var isDefault = (p.id === defaultModel || p.name === defaultModel || p.model === defaultModel) ? renderBadge('默认', 'mnote-ai-admin-badge--default') : ''; return '' + name + '' + model + '' + status + isDefault + '' + escapeHtml(p.baseUrl || p.base_url || p.endpoint || '-') + ''; }).join(''); } } if (overviewProvidersEl) overviewProvidersEl.textContent = String(Array.isArray(providers) ? providers.length : 0); if (overviewModelsEl) overviewModelsEl.textContent = String(modelCount); if (modelPolicyBody) { var modelPolicy = payload.modelPolicy || payload.model_policy || {}; var rows = [ ['Build 默认', modelPolicy.defaultBuildModel || modelPolicy.default_build_model || defaultModel || 'omniroute/freefirst'], ['Plan 默认', modelPolicy.defaultPlanModel || modelPolicy.default_plan_model || defaultModel || 'omniroute/freefirst'], ['Task 默认', modelPolicy.defaultTaskModel || modelPolicy.default_task_model || defaultModel || 'omniroute/freefirst'], ['Failover', Array.isArray(modelPolicy.failoverChains || modelPolicy.failover_chains) ? '已配置' : '未配置'] ]; modelPolicyBody.innerHTML = rows.map(function(row) { return '' + escapeHtml(row[0]) + '' + escapeHtml(row[1]) + ''; }).join(''); } if (toolPolicyBody) { var tools = payload.toolCatalog || payload.tool_catalog || []; toolPolicyBody.innerHTML = tools.length ? tools.map(function(t) { var action = t.defaultPolicy || t.default_policy || 'allow'; var tone = action === 'allow' ? 'mnote-ai-admin-badge--active' : ''; return '' + escapeHtml(t.name || '-') + '' + escapeHtml(t.description || '-') + '' + renderBadge(action, tone) + ''; }).join('') : '暂无工具目录'; } var piStatus = null; try { piStatus = await requestJson('/api/page-ai/pi/status', { method: 'GET' }); } catch (_err) { // Pi Lab 状态失败不阻塞 OpenHub / 管理页主面板。 } renderServicePanels(piStatus, payload); } // ── 2. 加载 access scopes ── async function loadAccessScopes() { var payload; try { payload = await requestJson('/api/ai-admin/access-scopes', { method: 'GET' }); } catch (_err) { try { payload = await requestJson('/api/ai-settings/access-scopes', { method: 'GET' }); } catch (err) { if (accessScopesMessage) setText(accessScopesMessage, 'access-scopes API 暂未实现,此为占位'); if (accessScopesList) accessScopesList.innerHTML = '
暂无文件夹授权数据
'; if (overviewScopesEl) overviewScopesEl.textContent = '-'; return; } } setText(accessScopesJson, payload); setText(accessScopesMessage, ''); var scopes = payload && (payload.allowedRoots || payload.allowed_roots || payload.scopes || payload.accessScopes || payload.access_scopes || payload.grants || []); if (overviewScopesEl) { overviewScopesEl.textContent = String(Array.isArray(scopes) ? scopes.length : 0); } if (accessScopesList && Array.isArray(scopes)) { if (scopes.length === 0) { accessScopesList.innerHTML = '
暂无文件夹授权
'; } else { accessScopesList.innerHTML = scopes.map(function(s) { var path = escapeHtml(s.rootPath || s.rootUri || s.path || s.folder || '-'); var permission = s.permission || 'read'; var meta = escapeHtml(s.source || s.scope || ''); var permBadge = permission === 'write' ? renderBadge('读写', 'mnote-ai-admin-badge--active') : renderBadge('只读'); return '
' + '
' + '
' + path + '
' + '
' + meta + '
' + '
' + '
' + permBadge + '
' + '
'; }).join(''); } } } async function loadSessionsAndReceipts() { var sessionsPayload = await requestJson('/api/page-ai/pi/sessions?limit=20', { method: 'GET' }); var receiptsUrl = isAdmin ? '/api/ai-admin/receipts?limit=50' : '/api/ai-settings/receipts?limit=50'; var receiptsPayload = await requestJson(receiptsUrl, { method: 'GET' }); var sessions = sessionsPayload.sessions || []; var receipts = receiptsPayload.receipts || []; if (sessionsBody) { sessionsBody.innerHTML = sessions.length ? sessions.map(function(s) { return '' + escapeHtml(s.title || s.preview || s.sessionId || '-') + '' + '' + escapeHtml(s.modelId || s.modelProvider || '-') + '' + '' + renderBadge(s.status || 'unknown', s.status === 'runtime_running' ? 'mnote-ai-admin-badge--active' : '') + '' + '' + escapeHtml(s.updatedAt || '-') + ''; }).join('') : '暂无 Pi Lab 会话'; } if (receiptsBody) { receiptsBody.innerHTML = receipts.length ? receipts.map(function(r) { var allowed = r.allowed === true; return '' + escapeHtml(r.toolName || r.tool_name || '-') + '' + '' + renderBadge(allowed ? '允许' : '拒绝', allowed ? 'mnote-ai-admin-badge--active' : '') + '' + '' + escapeHtml(r.diffSummary || r.diff_summary || r.denyReason || r.deny_reason || '-') + '' + '' + escapeHtml(r.createdAt || r.created_at || '-') + ''; }).join('') : '暂无工具回执'; } if (receiptsSummary) { receiptsSummary.textContent = '会话 ' + sessions.length + ' · 回执 ' + (receiptsPayload.receiptCount || receipts.length) + ' · 文件补丁 ' + (receiptsPayload.patchCount || 0); } if (usageSummary) { usageSummary.innerHTML = '
' + '
Pi Lab 会话
' + sessions.length + '

来自 ai_runtime_runs

' + '
工具回执
' + (receiptsPayload.receiptCount || receipts.length) + '

来自 ai_tool_events

' + '
文件补丁
' + (receiptsPayload.patchCount || 0) + '

来自 ai_file_patches

' + '
'; } } // ════════════════════════════════════════════════ // 3. Config 内联编辑 — 模型/工具/技能 MCP // ════════════════════════════════════════════════ // configData 保存最后一次 GET 到的全量配置 var configData = { providers: [], toolPolicies: [], skills: [], mcpServers: [] }; function objectValuesWithId(value) { return Object.keys(value || {}).map(function(id) { return { id: id, ...(value[id] || {}) }; }); } function renderProviderConfig(providers) { if (!modelsContainer) return; if (!providers || !providers.length) { modelsContainer.innerHTML = '
暂未配置 Provider
'; return; } var html = ''; providers.forEach(function(p, idx) { var allowedModels = Array.isArray(p.allowedModels) ? p.allowedModels.join(', ') : (p.allowedModels || ''); var failover = Array.isArray(p.failoverChains) ? p.failoverChains.join(', ') : (p.failoverChains || ''); var disabledAttr = isAdmin ? '' : ' disabled'; html += '
'; html += '
'; html += '' + escapeHtml(p.name || p.id || 'Provider ' + (idx + 1)) + ''; html += '' + renderTag(p.enabled !== false ? '已连接' : '未连接', p.enabled !== false ? 'green' : 'orange') + renderTag((Array.isArray(p.allowedModels) ? p.allowedModels.length : 0) + ' 个模型', 'blue') + ''; if (idx > 0 && isAdmin) { html += ''; } html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
引用环境变量名,前端不出现 raw key
'; html += '
'; html += '
'; html += '
'; html += '
'; var allModels = Array.isArray(p.allowedModels) ? p.allowedModels : []; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; html += '
'; }); modelsContainer.innerHTML = '
' + html; // 绑定删除事件 if (isAdmin) { modelsContainer.querySelectorAll('[data-action="delete-provider"]').forEach(function(btn) { btn.addEventListener('click', function() { var idx = parseInt(btn.getAttribute('data-idx'), 10); if (!isNaN(idx) && configData.providers && configData.providers.length > idx) { configData.providers.splice(idx, 1); renderProviderConfig(configData.providers); } }); }); } var providerFilter = modelsContainer.querySelector('[data-ai-admin-filter="providers"]'); if (providerFilter) { providerFilter.addEventListener('input', function() { var q = providerFilter.value.trim().toLowerCase(); modelsContainer.querySelectorAll('[data-provider-idx]').forEach(function(card) { card.style.display = !q || card.textContent.toLowerCase().includes(q) ? '' : 'none'; }); }); } } function renderToolsConfig(tools) { if (!toolsContainer) return; if (!tools || !tools.length) { toolsContainer.innerHTML = '
暂未配置工具策略
'; return; } var riskGroups = { dangerous: 0, moderate: 0, safe: 0, custom: 0 }; tools.forEach(function(t) { var risk = t.riskLevel || t.risk_level || t.risk || 'custom'; if (!riskGroups.hasOwnProperty(risk)) risk = 'custom'; riskGroups[risk] += 1; }); var stats = '
' + '
🔒 危险:' + riskGroups.dangerous + '
' + '
? 敏感:' + riskGroups.moderate + '
' + '
✓ 安全:' + riskGroups.safe + '
' + '
⌁ 自定义:' + riskGroups.custom + '
' + '
'; var search = '
'; var rows = tools.map(function(t, idx) { var disabledAttr = isAdmin ? '' : ' disabled'; return '' + '' + renderTag(t.name || '-', 'blue') + '' + '' + escapeHtml(t.description || '-') + '' + '' + '' + renderTag(t.defaultPolicy || 'allow', t.defaultPolicy === 'deny' ? 'red' : (t.defaultPolicy === 'ask' ? 'orange' : 'green')) + '' + ''; }).join(''); toolsContainer.innerHTML = stats + search + '
' + rows + '
工具说明策略当前
'; var toolFilter = toolsContainer.querySelector('[data-ai-admin-filter="tools"]'); if (toolFilter) { toolFilter.addEventListener('input', function() { var q = toolFilter.value.trim().toLowerCase(); toolsContainer.querySelectorAll('tbody tr').forEach(function(row) { row.style.display = !q || row.textContent.toLowerCase().includes(q) ? '' : 'none'; }); }); } } function renderSkillsConfig(skills) { if (!skillsContainer) return; if (!skills || !skills.length) { skillsContainer.innerHTML = '
暂未配置 Skills
'; return; } var enabledCount = skills.filter(function(s) { return s.enabled !== false; }).length; var items = skills.map(function(s, idx) { var disabledAttr = isAdmin ? '' : ' disabled'; return '
' + '
' + '
' + '' + '' + '
' + '
' + '' + (isAdmin ? '' : '') + '
'; }).join(''); skillsContainer.innerHTML = '
✓ 已启用:' + enabledCount + '/' + skills.length + '
' + '
' + items; if (isAdmin) { skillsContainer.querySelectorAll('[data-action="delete-skill"]').forEach(function(btn) { btn.addEventListener('click', function() { var idx = parseInt(btn.getAttribute('data-idx'), 10); if (!isNaN(idx) && configData.skills && configData.skills.length > idx) { configData.skills.splice(idx, 1); renderSkillsConfig(configData.skills); } }); }); } var skillFilter = skillsContainer.querySelector('[data-ai-admin-filter="skills"]'); if (skillFilter) { skillFilter.addEventListener('input', function() { var q = skillFilter.value.trim().toLowerCase(); skillsContainer.querySelectorAll('[data-skill-idx]').forEach(function(row) { row.style.display = !q || row.textContent.toLowerCase().includes(q) ? '' : 'none'; }); }); } } function renderMCPConfig(mcpServers) { if (!mcpContainer) return; if (!mcpServers || !mcpServers.length) { mcpContainer.innerHTML = '
暂未配置 MCP Server
'; return; } var items = mcpServers.map(function(s, idx) { var disabledAttr = isAdmin ? '' : ' disabled'; var secretRefsStr = Array.isArray(s.secretRefs) ? s.secretRefs.join(', ') : (s.secretRefs || ''); return '
' + '
' + '' + escapeHtml(s.name || s.id || 'MCP ' + (idx + 1)) + '' + '' + renderTag(s.transport || 'stdio', 'blue') + renderTag(s.facadeOnly !== false ? 'facade-only' : 'raw', s.facadeOnly !== false ? 'green' : 'orange') + '' + (isAdmin ? '' : '') + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
仅允许 env:// 或 secret:// 引用
' + '
' + '
' + '' + '
' + '
'; }).join(''); mcpContainer.innerHTML = '
' + items; if (isAdmin) { mcpContainer.querySelectorAll('[data-action="delete-mcp"]').forEach(function(btn) { btn.addEventListener('click', function() { var idx = parseInt(btn.getAttribute('data-idx'), 10); if (!isNaN(idx) && configData.mcpServers && configData.mcpServers.length > idx) { configData.mcpServers.splice(idx, 1); renderMCPConfig(configData.mcpServers); } }); }); } var mcpFilter = mcpContainer.querySelector('[data-ai-admin-filter="mcp"]'); if (mcpFilter) { mcpFilter.addEventListener('input', function() { var q = mcpFilter.value.trim().toLowerCase(); mcpContainer.querySelectorAll('[data-mcp-idx]').forEach(function(card) { card.style.display = !q || card.textContent.toLowerCase().includes(q) ? '' : 'none'; }); }); } } function collectProvidersFromDOM() { if (!modelsContainer) return []; var cards = modelsContainer.querySelectorAll('.mnote-ai-admin-provider-card'); return Array.from(cards).map(function(card) { function val(field) { var el = card.querySelector('[data-field="' + field + '"]'); if (!el) return ''; if (el.type === 'checkbox') return el.checked; return el.value; } var allowedRaw = val('allowedModels'); var failoverRaw = val('failoverChains'); return { id: val('id'), name: val('name'), baseUrl: val('baseUrl'), secretRef: val('secretRef'), allowedModels: allowedRaw ? allowedRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [], defaultModel: val('defaultModel'), defaultBuildModel: val('defaultBuildModel'), defaultPlanModel: val('defaultPlanModel'), defaultTaskModel: val('defaultTaskModel'), failoverChains: failoverRaw ? failoverRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [], enabled: val('enabled') }; }); } function collectToolsFromDOM() { if (!toolsContainer) return []; var selects = toolsContainer.querySelectorAll('[data-tool-idx]'); var tools = []; selects.forEach(function(sel) { var idx = parseInt(sel.getAttribute('data-tool-idx'), 10); if (!isNaN(idx) && configData.toolPolicies && configData.toolPolicies[idx]) { tools[idx] = tools[idx] || JSON.parse(JSON.stringify(configData.toolPolicies[idx])); tools[idx].defaultPolicy = sel.value; } }); return tools.filter(Boolean); } function collectSkillsFromDOM() { if (!skillsContainer) return []; var toggles = skillsContainer.querySelectorAll('[data-field="skill-enabled"]'); var skills = []; toggles.forEach(function(tog) { var idx = parseInt(tog.getAttribute('data-idx'), 10); if (!isNaN(idx) && configData.skills && configData.skills[idx]) { skills[idx] = skills[idx] || JSON.parse(JSON.stringify(configData.skills[idx])); skills[idx].enabled = tog.checked; var nameEl = skillsContainer.querySelector('[data-field="skill-name"][data-idx="' + idx + '"]'); var descriptionEl = skillsContainer.querySelector('[data-field="skill-description"][data-idx="' + idx + '"]'); skills[idx].name = nameEl ? nameEl.value.trim() : skills[idx].name; skills[idx].description = descriptionEl ? descriptionEl.value.trim() : ''; } }); return skills.filter(Boolean); } function collectMCPFromDOM() { if (!mcpContainer) return []; var cards = mcpContainer.querySelectorAll('[data-mcp-idx]'); var mcps = []; cards.forEach(function(card) { var idx = parseInt(card.getAttribute('data-mcp-idx'), 10); if (isNaN(idx) || !configData.mcpServers || !configData.mcpServers[idx]) return; function val(field) { var el = card.querySelector('[data-field="' + field + '"][data-idx="' + idx + '"]'); if (!el) return ''; if (el.type === 'checkbox') return el.checked; return el.value; } mcps[idx] = JSON.parse(JSON.stringify(configData.mcpServers[idx])); mcps[idx].name = val('mcp-name'); mcps[idx].enabled = val('mcp-enabled'); mcps[idx].transport = val('mcp-transport'); mcps[idx].command = val('mcp-command'); mcps[idx].url = val('mcp-url'); mcps[idx].networkPolicy = val('mcp-network'); var secretsRaw = val('mcp-secrets'); mcps[idx].secretRefs = secretsRaw ? secretsRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : []; mcps[idx].facadeOnly = val('mcp-facade'); mcps[idx].sandbox = true; }); return mcps.filter(Boolean); } // ── 添加空条目 ── function addEmptyProvider() { if (!configData.providers) configData.providers = []; configData.providers.push({ id: '', name: '', baseUrl: '', secretRef: '', allowedModels: [], defaultModel: '', defaultBuildModel: '', defaultPlanModel: '', defaultTaskModel: '', failoverChains: [], enabled: true }); renderProviderConfig(configData.providers); } function addEmptySkill() { if (!configData.skills) configData.skills = []; configData.skills.push({ id: '', name: '', description: '', enabled: true }); renderSkillsConfig(configData.skills); } function addEmptyMCP() { if (!configData.mcpServers) configData.mcpServers = []; configData.mcpServers.push({ id: '', name: '', description: '', enabled: true, transport: 'stdio', command: '', url: '', networkPolicy: 'deny-all', secretRefs: [], facadeOnly: true }); renderMCPConfig(configData.mcpServers); } // ── 加载 config ── async function loadConfig() { var payload; try { payload = await requestJson('/api/ai-admin/settings', { method: 'GET' }); } catch (err) { if (modelsContainer) modelsContainer.innerHTML = '
模型配置 API 暂不可用
'; if (toolsContainer) toolsContainer.innerHTML = '
工具配置 API 暂不可用
'; if (skillsContainer) skillsContainer.innerHTML = '
技能配置 API 暂不可用
'; if (mcpContainer) mcpContainer.innerHTML = '
MCP 配置 API 暂不可用
'; return; } configData.providers = objectValuesWithId(payload.providers); configData.toolPolicies = (payload.toolCatalog || []).map(function(tool) { return { ...tool, defaultPolicy: (payload.tools || {})[tool.name] || tool.defaultPolicy }; }); configData.skills = objectValuesWithId(payload.skills); configData.mcpServers = objectValuesWithId(payload.mcpServers); renderProviderConfig(configData.providers); renderToolsConfig(configData.toolPolicies); renderSkillsConfig(configData.skills); renderMCPConfig(configData.mcpServers); } // ── 保存 config ── async function saveConfig(scope) { var body = {}; if (scope === 'models' || !scope) { var providers = collectProvidersFromDOM(); body.providers = {}; body.allowedModels = []; providers.forEach(function(provider) { if (!provider.id) return; body.providers[provider.id] = { name: provider.name, enabled: provider.enabled, secretRef: provider.secretRef, defaultModel: provider.defaultModel, baseUrl: provider.baseUrl, allowedModels: provider.allowedModels, defaultBuildModel: provider.defaultBuildModel, defaultPlanModel: provider.defaultPlanModel, defaultTaskModel: provider.defaultTaskModel, failoverChains: provider.failoverChains }; body.allowedModels = body.allowedModels.concat(provider.allowedModels); if (!body.defaultModel && provider.defaultModel) body.defaultModel = provider.defaultModel; if (!body.buildPlanTask) { body.buildPlanTask = { default: provider.defaultBuildModel || provider.defaultModel || '', failover: provider.failoverChains || [] }; } }); } if (scope === 'tools' || !scope) { body.tools = {}; collectToolsFromDOM().forEach(function(tool) { body.tools[tool.name] = tool.defaultPolicy; }); } if (scope === 'skills' || !scope) { body.skills = {}; collectSkillsFromDOM().forEach(function(skill) { var id = skill.id || skill.name; if (!id) return; body.skills[id] = { name: skill.name, enabled: skill.enabled, description: skill.description || '' }; }); body.mcpServers = {}; collectMCPFromDOM().forEach(function(server) { var id = server.id || server.name; if (!id) return; body.mcpServers[id] = { name: server.name, enabled: server.enabled, url: server.url || '', transport: server.transport || 'stdio', command: server.command || '', networkPolicy: server.networkPolicy || 'deny-all', secretRefs: server.secretRefs || [], facadeOnly: server.facadeOnly !== false, sandbox: true }; }); } try { await requestJson('/api/ai-admin/settings', { method: 'PUT', body: JSON.stringify(body), }); await loadConfig(); if (scope === 'models') { showSaveStatus(modelsSaveStatus, 'success', '模型配置已保存'); } else if (scope === 'tools') { showSaveStatus(toolsSaveStatus, 'success', '工具策略已保存'); } else if (scope === 'skills') { showSaveStatus(skillsSaveStatus, 'success', '技能/MCP 配置已保存'); } } catch (err) { if (scope === 'models') { showSaveStatus(modelsSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误')); } else if (scope === 'tools') { showSaveStatus(toolsSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误')); } else if (scope === 'skills') { showSaveStatus(skillsSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误')); } } } // ── 绑定保存/添加按钮 ── if (isAdmin) { var saveModelsBtn = root.querySelector('[data-action="save-models"]'); if (saveModelsBtn) { saveModelsBtn.addEventListener('click', function () { saveConfig('models'); }); } var saveToolsBtn = root.querySelector('[data-action="save-tools"]'); if (saveToolsBtn) { saveToolsBtn.addEventListener('click', function () { saveConfig('tools'); }); } var saveSkillsBtn = root.querySelector('[data-action="save-skills"]'); if (saveSkillsBtn) { saveSkillsBtn.addEventListener('click', function () { saveConfig('skills'); }); } var addProviderBtn = root.querySelector('[data-action="add-provider"]'); if (addProviderBtn) { addProviderBtn.addEventListener('click', addEmptyProvider); } var addSkillBtn = root.querySelector('[data-action="add-skill"]'); if (addSkillBtn) { addSkillBtn.addEventListener('click', addEmptySkill); } var addMCPBtn = root.querySelector('[data-action="add-mcp"]'); if (addMCPBtn) { addMCPBtn.addEventListener('click', addEmptyMCP); } } // ── 启动 ── loadEffective().catch(function (err) { if (effectiveJson) setText(effectiveJson, { error: err.message || '加载失败' }); }); loadAccessScopes().catch(function (err) { if (accessScopesMessage) setText(accessScopesMessage, err.message || '加载失败'); }); loadSessionsAndReceipts().catch(function (err) { if (receiptsSummary) setText(receiptsSummary, err.message || '加载会话与回执失败'); }); loadConfig().catch(function (err) { // 静默处理,各个容器已显示错误状态 console.warn('loadConfig 失败:', err && err.message); }); loadUsers().catch(function (err) { if (userSettingsEditor) userSettingsEditor.innerHTML = '
' + escapeHtml(err.message || '加载用户失败') + '
'; }); } window.MNOTEInitAiAdminPage = initAiAdminPage; var shell = document.body && document.body.getAttribute('data-mnote-shell'); if (shell === 'admin' || shell === 'user-ai-admin' || document.querySelector('[data-testid="mnote-ai-admin-page"]')) { initAiAdminPage(document); } document.addEventListener('DOMContentLoaded', function () { initAiAdminPage(document); }, { once: true }); })(); "#; /// MNOTE AI 管理页面 /// /// 左侧分页导航,包含概览、模型配置、工具权限、技能/MCP 等面板。 /// 模型/工具/技能面板由 JS 读取 config API 后渲染内联编辑表单。 /// 用户模式下所有编辑控件禁用。 #[component] pub fn AiManagementPage( #[prop(optional)] workspace_name: Option, #[prop(optional, default = true)] is_admin: bool, ) -> impl IntoView { let workspace_name = workspace_name .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .unwrap_or_else(|| "工作区".to_string()); let role_text = if is_admin { "管理员模式 — 可管理 AI Providers 和访问策略" } else { "用户模式 — 查看 AI 配置与已授权文件夹" }; let access_policy_href = if is_admin { "/admin/access-policy" } else { "/user/access-policy" }; let access_policy_label = if is_admin { "前往管理员授权管理" } else { "查看我的文件夹授权" }; let config_json = format!( r#"{{"isAdmin":{},"workspaceName":"{}"}}"#, if is_admin { "true" } else { "false" }, workspace_name.replace('"', "\\\"") ); view! {
"首页" "/" "管理后台"

"AI 管理"

{role_text}

// ════════════════════════════════════════ // 1. 概览 (Overview) // ════════════════════════════════════════

"概览"

"AI 服务运行状态摘要"

"AI Providers" "..." "已配置 provider 数量"
"可用模型" "..." "模型配置数"
"已授权文件夹" "..." "AI 可访问的本地文件夹数"
"工作区" {workspace_name.clone()} "当前上下文"
// ════════════════════════════════════════ // 2. 模型配置(内联编辑) // ════════════════════════════════════════ // ════════════════════════════════════════ // 3. 工具权限(内联编辑) // ════════════════════════════════════════ // ════════════════════════════════════════ // 4. 技能 / MCP(内联编辑) // ════════════════════════════════════════ // ════════════════════════════════════════ // 5. Access Scopes(只读) // ════════════════════════════════════════ // ════════════════════════════════════════ // 6. Sessions & Receipts // ════════════════════════════════════════
} } #[cfg(test)] mod tests { use super::*; use crate::ssr::render_view; #[test] fn ai_admin_page_renders_all_panels() { let html = render_view(view! { }); assert!(html.contains("mnote-ai-admin-page")); assert!(html.contains("data-ai-admin-role=\"admin\"")); assert!(html.contains("概览")); assert!(html.contains("模型配置")); assert!(html.contains("Access Scopes")); assert!(html.contains("Sessions & Receipts")); assert!(html.contains("技能 / MCP")); assert!(html.contains("工具权限")); assert!(html.contains("AI 管理")); } #[test] fn ai_admin_page_user_mode_hides_admin_buttons() { let html = render_view(view! { }); assert!(html.contains("data-ai-admin-role=\"user\"")); assert!(html.contains("用户模式 — 查看 AI 配置与已授权文件夹")); assert!(html.contains("/user/access-policy")); assert!(html.contains("查看我的文件夹授权")); // 内嵌脚本包含 action 字符串;用户态由 isAdmin=false 禁用编辑。 assert!(html.contains(r#""isAdmin":false"#)); assert!(AI_ADMIN_SCRIPT.contains("var disabledAttr = isAdmin ? '' : ' disabled'")); } #[test] fn ai_admin_page_admin_mode_shows_save_and_add_buttons() { let html = render_view(view! { }); assert!(html.contains("data-ai-admin-role=\"admin\"")); assert!(html.contains("管理员模式 — 可管理 AI Providers 和访问策略")); assert!(html.contains("/admin/access-policy")); assert!(html.contains("前往管理员授权管理")); // Admin 模式下有保存和添加按钮 assert!(html.contains("data-action=\"save-models\"")); assert!(html.contains("data-action=\"save-tools\"")); assert!(html.contains("data-action=\"save-skills\"")); assert!(html.contains("data-action=\"add-provider\"")); assert!(html.contains("data-action=\"add-skill\"")); assert!(html.contains("data-action=\"add-mcp\"")); } #[test] fn ai_admin_page_has_editable_containers_for_models_tools_skills() { let html = render_view(view! { }); // 面板容器 data 属性 assert!(html.contains("data-ai-admin-models-container")); assert!(html.contains("data-ai-admin-tools-container")); assert!(html.contains("data-ai-admin-skills-container")); assert!(html.contains("data-ai-admin-users-list")); assert!(html.contains("data-ai-admin-user-settings")); assert!(html.contains("/api/ai-admin/users/")); assert!(html.contains("data-ai-admin-mcp-container")); // 保存状态容器 assert!(html.contains("data-ai-admin-models-save-status")); assert!(html.contains("data-ai-admin-tools-save-status")); assert!(html.contains("data-ai-admin-skills-save-status")); } #[test] fn ai_admin_page_has_access_policy_link_and_no_access_scopes_write_forms() { let html = render_view(view! { }); assert!(html.contains("data-testid=\"mnote-ai-admin-access-policy-link\"")); assert!(html.contains("/admin/access-policy")); // 不得有 access-scopes 写请求表单或按钮 assert!(!html.contains("data-admin-form=\"create-share-grant\"")); assert!(!html.contains("data-admin-action=\"revoke-access-grant\"")); assert!(!html.contains("mnote-admin-create-share-grant-submit")); assert!(!html.contains("name=\"rootPath\"")); assert!(!html.contains("name=\"targetUserId\"")); } #[test] fn ai_admin_page_script_fetches_config_effective_and_access_scopes() { assert!(AI_ADMIN_SCRIPT.contains("/api/ai-admin/settings")); assert!(AI_ADMIN_SCRIPT.contains("/api/ai-admin/effective")); assert!(AI_ADMIN_SCRIPT.contains("/api/ai-admin/access-scopes")); assert!(AI_ADMIN_SCRIPT.contains("/api/ai-settings/effective")); assert!(AI_ADMIN_SCRIPT.contains("/api/ai-settings/access-scopes")); // 脚本包含保存逻辑 assert!(AI_ADMIN_SCRIPT.contains("PUT")); assert!(AI_ADMIN_SCRIPT.contains("saveConfig")); assert!(AI_ADMIN_SCRIPT.contains("loadConfig")); } #[test] fn ai_admin_page_contains_edit_form_css() { let html = render_view(view! { }); assert!(html.contains("mnote-ai-admin-input")); assert!(html.contains("mnote-ai-admin-select")); assert!(html.contains("mnote-ai-admin-btn")); assert!(html.contains("mnote-ai-admin-btn--primary")); assert!(html.contains("mnote-ai-admin-toggle")); assert!(html.contains("mnote-ai-admin-save-status")); assert!(html.contains("mnote-ai-admin-provider-card")); } #[test] fn ai_admin_page_stress_default_props() { let html = render_view(view! { }); assert!(html.contains("mnote-ai-admin-page")); assert!(html.contains("data-ai-admin-role=\"admin\"")); } #[test] fn ai_admin_page_script_provides_model_role_default_dropdowns() { // 脚本应包含 Build/Plan/Task 模型下拉选择和 failover 输入 assert!(AI_ADMIN_SCRIPT.contains("defaultBuildModel")); assert!(AI_ADMIN_SCRIPT.contains("defaultPlanModel")); assert!(AI_ADMIN_SCRIPT.contains("defaultTaskModel")); assert!(AI_ADMIN_SCRIPT.contains("failoverChains")); assert!(AI_ADMIN_SCRIPT.contains("allowedModels")); } #[test] fn ai_admin_page_script_tool_policy_has_allow_ask_deny() { // 工具策略应包含三个选项 assert!(AI_ADMIN_SCRIPT.contains("value=\"allow\"")); assert!(AI_ADMIN_SCRIPT.contains("value=\"ask\"")); assert!(AI_ADMIN_SCRIPT.contains("value=\"deny\"")); } #[test] fn ai_admin_page_script_mcp_has_required_fields() { // MCP 编辑应包含 transport/command/url/network/secret/facade assert!(AI_ADMIN_SCRIPT.contains("mcp-transport")); assert!(AI_ADMIN_SCRIPT.contains("mcp-command")); assert!(AI_ADMIN_SCRIPT.contains("mcp-url")); assert!(AI_ADMIN_SCRIPT.contains("mcp-network")); assert!(AI_ADMIN_SCRIPT.contains("mcp-secrets")); assert!(AI_ADMIN_SCRIPT.contains("mcp-facade")); // 只显示禁止明文密钥的说明,不提供 apiKey 字段。 assert!(!AI_ADMIN_SCRIPT.contains("apiKey")); } #[test] fn ai_admin_page_script_uses_get_only_for_access_scopes() { assert!(!AI_ADMIN_SCRIPT.contains("access-scopes', { method: 'POST'")); assert!(!AI_ADMIN_SCRIPT.contains("access-scopes', { method: 'PUT'")); assert!(!AI_ADMIN_SCRIPT.contains("access-scopes', { method: 'DELETE'")); } }