Files
mnote/rust/crates/mnote-web/src/ssr/pages/ai_admin.rs
T

2739 lines
124 KiB
Rust
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.
//! 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 '<span class="mnote-ai-admin-badge' + (className ? ' ' + className : '') + '">' + escapeHtml(text) + '</span>';
}
function renderTag(text, tone) {
return '<span class="mnote-ai-admin-tag' + (tone ? ' mnote-ai-admin-tag--' + tone : '') + '">' + escapeHtml(text) + '</span>';
}
function renderKv(label, value) {
return '<div class="mnote-ai-admin-kv"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value == null || value === '' ? '-' : value) + '</strong></div>';
}
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 '<div class="mnote-ai-admin-permission-group">' +
'<div class="mnote-ai-admin-permission-group-head"><strong>' + escapeHtml(title) + '</strong>' + renderTag(countLabel, 'blue') + '</div>' +
'<div class="mnote-ai-admin-permission-group-body">' + (bodyHtml || '<div class="mnote-ai-admin-empty">暂无数据</div>') + '</div>' +
'</div>';
}
function renderSelectablePermissionRow(options) {
var select = options.selectHtml || '';
return '<div class="mnote-ai-admin-permission-row">' +
'<div class="mnote-ai-admin-permission-row-main"><strong>' + escapeHtml(options.title || '-') + '</strong><small>' + escapeHtml(options.subtitle || '') + '</small></div>' +
'<div class="mnote-ai-admin-permission-row-actions">' + (options.metaHtml || '') + select + (options.resetHtml || '') + '</div>' +
'</div>';
}
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 =
'<div class="mnote-ai-admin-admin-list">' +
'<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>OpenHub</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag(openhubStatus, 'green') +
'<a href="/page-ai/openhub/admin" class="mnote-ai-admin-policy-link">打开 OpenHub Admin</a>' +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body">' +
'<div class="mnote-ai-admin-kv-grid">' +
renderKv('定位', '默认 Page AI 主线') +
renderKv('Provider/模型真相', 'OpenHub 保持独立,不被 Pi Lab 替换') +
renderKv('MNote 侧入口', '/page-ai/openhub/ai') +
renderKv('管理入口', '/page-ai/openhub/admin') +
'</div>' +
'</div>' +
'</div>' +
'<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>Pi Lab</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag(piRunning ? '运行中' : '待启动', piRunning ? 'green' : 'orange') +
renderTag(piRuntime, 'blue') +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body">' +
'<div class="mnote-ai-admin-kv-grid">' +
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 : '<workspace>/.mnote/ai/pi-sessions/<actor>/<session>') +
'</div>' +
'</div>' +
'</div>' +
'</div>';
}
if (channelsPanel) {
var providerCards = providers.length ? providers.map(function(p) {
var model = p.defaultModel || p.default_model || p.model || defaultModel || 'omniroute/freefirst';
return '<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>' + escapeHtml(p.name || p.id || p.provider || 'Provider') + '</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag(p.enabled !== false ? '启用' : '停用', p.enabled !== false ? 'green' : 'orange') +
renderTag(p.id || p.provider || 'provider', 'blue') +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body"><div class="mnote-ai-admin-kv-grid">' +
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 不进前端') +
'</div></div>' +
'</div>';
}).join('') : '<div class="mnote-ai-admin-empty">暂无 Provider 配置</div>';
channelsPanel.innerHTML =
'<div class="mnote-ai-admin-admin-list">' +
'<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>Omniroute</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag('默认渠道', 'green') + renderTag('Pi: ' + piDefaultModel, 'blue') +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body"><div class="mnote-ai-admin-kv-grid">' +
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') +
'</div></div>' +
'</div>' +
providerCards +
'</div>';
}
if (knowledgePanel) {
var lightrag = effectivePayload && (effectivePayload.lightragProvider || effectivePayload.lightrag_provider) || {};
knowledgePanel.innerHTML =
'<div class="mnote-ai-admin-admin-list">' +
'<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>LightRAG</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag('唯一默认 provider', 'green') + renderTag('MNote facade', 'blue') +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body">' +
'<div class="mnote-ai-admin-kv-grid">' +
renderKv('Provider', lightrag.provider || 'lightrag') +
renderKv('查询入口', 'mnote.knowledge_rag.query') +
renderKv('引用回跳', 'citation / open-reference') +
renderKv('Pi 接入方式', '只能通过 MNote knowledge facade 查询') +
'</div>' +
'</div>' +
'</div>' +
'<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>知识源</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag('复用 allowed roots', 'green') + renderTag('不引入第二套 RAG', 'orange') +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body">' +
'<p class="mnote-ai-admin-mini-desc">资料 source/index/status 后续接 /api/knowledge-rag/*;目录授权继续来自 MNote allowed roots,不在 AI 管理页维护第二套目录。</p>' +
'</div>' +
'</div>' +
'</div>';
}
if (healthGrid) {
healthGrid.innerHTML = [
['OpenHub 默认入口', '保留', 'Page AI 主线未被 Pi Lab 替换'],
['Pi Lab', piRunning ? '运行中' : '可启动', 'runtime=' + piRuntime + ' · model=' + piDefaultModel],
['LightRAG', '默认', '唯一默认 knowledge providerPi 通过 MNote facade 查询'],
['Control-plane', effectivePayload && (effectivePayload.sourceOfTruth || effectivePayload.source_of_truth) || 'directory_grants', '授权、策略、receipt 真相层']
].map(function(item) {
return '<div class="mnote-ai-admin-mini-panel"><div class="mnote-ai-admin-mini-title">' + escapeHtml(item[0]) + '</div><div>' + renderBadge(item[1], 'mnote-ai-admin-badge--active') + '</div><p class="mnote-ai-admin-mini-desc">' + escapeHtml(item[2]) + '</p></div>';
}).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 = '<div class="mnote-ai-admin-empty">暂无用户</div>';
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 '<tr data-user-row="' + escapeHtml(user.id) + '">' +
'<td>' + escapeHtml(String(idx + 1)) + '</td>' +
'<td><span style="display:inline-flex;align-items:center;gap:6px;">👤 <strong>' + escapeHtml(username) + '</strong></span><div style="color:rgba(0,0,0,0.45);font-size:12px;">' + escapeHtml(user.email || user.id) + '</div></td>' +
'<td>' + renderTag(role === 'admin' ? '管理员' : '普通', role === 'admin' ? 'blue' : '') + '</td>' +
'<td>' + renderTag(status, statusTone) + '</td>' +
'<td><span style="color:rgba(0,0,0,0.45);font-size:12px;">' + escapeHtml(user.workspace_path || user.workspacePath || '未初始化') + '</span></td>' +
'<td><div class="mnote-ai-admin-icon-actions">' +
'<button type="button" class="mnote-ai-admin-icon-btn" data-user-action="edit" data-user-id="' + escapeHtml(user.id) + '" title="编辑用户">✎</button>' +
'<button type="button" class="mnote-ai-admin-icon-btn" data-user-action="models" data-user-id="' + escapeHtml(user.id) + '" title="模型权限">⌘</button>' +
'<button type="button" class="mnote-ai-admin-icon-btn" data-user-action="tools" data-user-id="' + escapeHtml(user.id) + '" title="工具权限">🛡</button>' +
'<button type="button" class="mnote-ai-admin-icon-btn" data-user-action="skills" data-user-id="' + escapeHtml(user.id) + '" title="技能权限">⚡</button>' +
'<button type="button" class="mnote-ai-admin-icon-btn" data-user-action="mcp" data-user-id="' + escapeHtml(user.id) + '" title="MCP 权限">◇</button>' +
'<button type="button" class="mnote-ai-admin-icon-btn" data-user-action="roots" data-user-id="' + escapeHtml(user.id) + '" title="目录权限">📁</button>' +
'</div></td>' +
'</tr>';
}).join('');
usersList.innerHTML = '<div class="mnote-ai-admin-table-wrap"><table class="mnote-ai-admin-table">' +
'<thead><tr><th>ID</th><th>用户名</th><th>角色</th><th>状态</th><th>工作空间</th><th>操作</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table></div>';
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 = '<div class="mnote-ai-admin-empty">该用户承载全局 AI 策略。请使用左侧“模型配置”“工具权限”“技能 / MCP”页面修改默认策略。</div>';
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: '<small>' + escapeHtml(usageText) + '</small>',
selectHtml: '<label class="mnote-ai-admin-toggle" title="启用模型"><input type="checkbox" data-user-model="' + escapeHtml(model.id) + '"' + (allowedIds.has(model.id) ? ' checked' : '') + '><span class="mnote-ai-admin-toggle-slider"></span></label>'
});
}).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 = '<select class="mnote-ai-admin-select" data-user-tool="' + escapeHtml(tool.name) + '">' +
'<option value="deny"' + (current === 'deny' ? ' selected' : '') + '>拒绝</option>' +
(maxRank >= 1 ? '<option value="ask"' + (current === 'ask' ? ' selected' : '') + '>审批</option>' : '') +
(maxRank >= 2 ? '<option value="allow"' + (current === 'allow' ? ' selected' : '') + '>允许</option>' : '') +
'</select>';
return renderSelectablePermissionRow({
title: tool.name,
subtitle: tool.description || ('全局 ' + actionLabel(tool.globalAction || 'allow')),
metaHtml: renderTag(actionLabel(current), actionTone(current)),
selectHtml: selectHtml,
resetHtml: tool.hasOverride ? '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-user-reset-tool="' + escapeHtml(tool.name) + '">重置</button>' : ''
});
}).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 '<div class="mnote-ai-admin-user-policy-row"><span><strong>' + escapeHtml(tool.name) + '</strong><small>全局 ' + escapeHtml(actionLabel(tool.globalAction)) + '</small></span>' +
'<select class="mnote-ai-admin-select" data-user-tool="' + escapeHtml(tool.name) + '">' +
'<option value="deny"' + (tool.action === 'deny' ? ' selected' : '') + '>拒绝</option>' +
(maxRank >= 1 ? '<option value="ask"' + (tool.action === 'ask' ? ' selected' : '') + '>审批</option>' : '') +
(maxRank >= 2 ? '<option value="allow"' + (tool.action === 'allow' ? ' selected' : '') + '>允许</option>' : '') +
'</select></div>';
}).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: '<label class="mnote-ai-admin-toggle"><input type="checkbox" data-user-skill="' + escapeHtml(skill.id) + '"' +
(skill.enabled ? ' checked' : '') + (skill.globallyEnabled ? '' : ' disabled') + '><span class="mnote-ai-admin-toggle-slider"></span></label>',
resetHtml: skill.hasOverride ? '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-user-reset-skill="' + escapeHtml(skill.id) + '">重置</button>' : ''
});
}).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: '<label class="mnote-ai-admin-toggle"><input type="checkbox" data-user-mcp="' + escapeHtml(server.id) + '"' +
(server.enabled ? ' checked' : '') + (server.globallyEnabled ? '' : ' disabled') + '><span class="mnote-ai-admin-toggle-slider"></span></label>',
resetHtml: server.hasOverride ? '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-user-reset-mcp="' + escapeHtml(server.id) + '">重置</button>' : ''
});
}).join('');
if (mcps) mcps = renderPermissionGroup('MCP Servers', (payload.mcpServers || []).length + ' 项', mcps);
var roots = (payload.allowedRoots || payload.accessScopes || payload.roots || []).map(function(rootItem) {
return '<div class="mnote-ai-admin-access-scope-row"><div><div class="mnote-ai-admin-access-scope-path">' + escapeHtml(rootItem.rootPath || rootItem.path || rootItem.rootUri || '-') + '</div>' +
'<div class="mnote-ai-admin-access-scope-meta">' + escapeHtml(rootItem.source || rootItem.scope || 'directory_grants') + '</div></div>' +
renderTag(rootItem.permission === 'write' ? '读写' : '只读', rootItem.permission === 'write' ? 'green' : '') + '</div>';
}).join('');
var activePanel = selectedUserPanel || 'models';
var header =
'<div class="mnote-ai-admin-user-tabbar">' +
'<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small' + (activePanel === 'models' || activePanel === 'edit' ? ' mnote-ai-admin-btn--primary' : '') + '" data-user-tab="models">模型权限</button>' +
'<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small' + (activePanel === 'tools' ? ' mnote-ai-admin-btn--primary' : '') + '" data-user-tab="tools">工具权限</button>' +
'<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small' + (activePanel === 'skills' ? ' mnote-ai-admin-btn--primary' : '') + '" data-user-tab="skills">技能权限</button>' +
'<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small' + (activePanel === 'mcp' ? ' mnote-ai-admin-btn--primary' : '') + '" data-user-tab="mcp">MCP</button>' +
'<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small' + (activePanel === 'roots' ? ' mnote-ai-admin-btn--primary' : '') + '" data-user-tab="roots">目录权限</button>' +
'<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-action="save-user-settings" style="margin-left:auto;">保存用户配置</button>' +
'</div>';
var body = '';
if (activePanel === 'tools') {
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>工具权限</h3><small>按 OpenHub risk_level 分组</small></div>' + (tools || '<div class="mnote-ai-admin-empty">暂无工具</div>') + '</div>';
} else if (activePanel === 'skills') {
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>Skills</h3><small>全局启用后用户可单独降权</small></div>' + (skills || '<div class="mnote-ai-admin-empty">暂无 Skill</div>') + '</div>';
} else if (activePanel === 'mcp') {
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>MCP</h3><small>只允许 MNote facade/sandbox MCP</small></div>' + (mcps || '<div class="mnote-ai-admin-empty">暂无 MCP</div>') + '</div>';
} else if (activePanel === 'roots') {
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>目录权限</h3><a href="/admin/access-policy" class="mnote-ai-admin-policy-link">统一授权管理</a></div>' + (roots || '<div class="mnote-ai-admin-empty">目录权限由统一 allowed roots 管理。请到目录权限页调整。</div>') + '</div>';
} else {
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>模型权限</h3><small>按 provider 分组,参考 OpenHub 用户模型权限</small></div>' + (modelRows || '<div class="mnote-ai-admin-empty">暂无可用模型</div>') +
'<label class="mnote-ai-admin-label">默认模型</label><select class="mnote-ai-admin-select" data-user-default-model>' +
catalogModels.filter(function(model) { return allowedIds.has(model.id); }).map(function(model) {
return '<option value="' + escapeHtml(model.id) + '"' + (payload.defaultModel === model.id ? ' selected' : '') + '>' + escapeHtml(model.name || model.id) + '</option>';
}).join('') + '</select></div>';
}
userSettingsEditor.innerHTML =
'<div class="mnote-ai-admin-user-editor-head"><div><h3>' + escapeHtml(payload.userId) + '</h3><p>逐用户配置只能在管理员全局范围内降权或选择;目录权限复用 MNote allowed roots。</p></div>' +
'<span class="mnote-ai-admin-actions-bar">' + renderTag(payload.defaultModel || '未设默认模型', 'blue') + renderTag((payload.allowedModels || []).length + ' 个模型', 'green') + '</span></div>' +
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 = '<tr><td colspan="4" class="mnote-ai-admin-empty">暂无数据</td></tr>';
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 = '<tr><td colspan="4" class="mnote-ai-admin-empty">暂无配置</td></tr>';
} 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 '<tr><td>' + name + '</td><td>' + model + '</td><td>' + status + isDefault + '</td><td style="font-size:12px;color:#8B8782;overflow-wrap:anywhere;max-width:280px;">' + escapeHtml(p.baseUrl || p.base_url || p.endpoint || '-') + '</td></tr>';
}).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 '<tr><td>' + escapeHtml(row[0]) + '</td><td>' + escapeHtml(row[1]) + '</td></tr>';
}).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 '<tr><td>' + escapeHtml(t.name || '-') + '</td><td>' + escapeHtml(t.description || '-') + '</td><td>' + renderBadge(action, tone) + '</td></tr>';
}).join('') : '<tr><td colspan="3" class="mnote-ai-admin-empty">暂无工具目录</td></tr>';
}
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 = '<div class="mnote-ai-admin-empty">暂无文件夹授权数据</div>';
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 = '<div class="mnote-ai-admin-empty">暂无文件夹授权</div>';
} 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 '<div class="mnote-ai-admin-access-scope-row">' +
'<div>' +
'<div class="mnote-ai-admin-access-scope-path">' + path + '</div>' +
'<div class="mnote-ai-admin-access-scope-meta">' + meta + '</div>' +
'</div>' +
'<div>' + permBadge + '</div>' +
'</div>';
}).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 '<tr><td>' + escapeHtml(s.title || s.preview || s.sessionId || '-') + '</td>' +
'<td>' + escapeHtml(s.modelId || s.modelProvider || '-') + '</td>' +
'<td>' + renderBadge(s.status || 'unknown', s.status === 'runtime_running' ? 'mnote-ai-admin-badge--active' : '') + '</td>' +
'<td>' + escapeHtml(s.updatedAt || '-') + '</td></tr>';
}).join('') : '<tr><td colspan="4" class="mnote-ai-admin-empty">暂无 Pi Lab 会话</td></tr>';
}
if (receiptsBody) {
receiptsBody.innerHTML = receipts.length ? receipts.map(function(r) {
var allowed = r.allowed === true;
return '<tr><td>' + escapeHtml(r.toolName || r.tool_name || '-') + '</td>' +
'<td>' + renderBadge(allowed ? '允许' : '拒绝', allowed ? 'mnote-ai-admin-badge--active' : '') + '</td>' +
'<td>' + escapeHtml(r.diffSummary || r.diff_summary || r.denyReason || r.deny_reason || '-') + '</td>' +
'<td>' + escapeHtml(r.createdAt || r.created_at || '-') + '</td></tr>';
}).join('') : '<tr><td colspan="4" class="mnote-ai-admin-empty">暂无工具回执</td></tr>';
}
if (receiptsSummary) {
receiptsSummary.textContent = '会话 ' + sessions.length + ' · 回执 ' +
(receiptsPayload.receiptCount || receipts.length) + ' · 文件补丁 ' +
(receiptsPayload.patchCount || 0);
}
if (usageSummary) {
usageSummary.innerHTML = '<div class="mnote-ai-admin-subgrid">' +
'<div class="mnote-ai-admin-mini-panel"><div class="mnote-ai-admin-mini-title">Pi Lab 会话</div><strong>' + sessions.length + '</strong><p class="mnote-ai-admin-mini-desc">来自 ai_runtime_runs</p></div>' +
'<div class="mnote-ai-admin-mini-panel"><div class="mnote-ai-admin-mini-title">工具回执</div><strong>' + (receiptsPayload.receiptCount || receipts.length) + '</strong><p class="mnote-ai-admin-mini-desc">来自 ai_tool_events</p></div>' +
'<div class="mnote-ai-admin-mini-panel"><div class="mnote-ai-admin-mini-title">文件补丁</div><strong>' + (receiptsPayload.patchCount || 0) + '</strong><p class="mnote-ai-admin-mini-desc">来自 ai_file_patches</p></div>' +
'</div>';
}
}
// ════════════════════════════════════════════════
// 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 = '<div class="mnote-ai-admin-empty">暂未配置 Provider</div>';
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 += '<div class="mnote-ai-admin-provider-card" data-provider-idx="' + idx + '">';
html += '<div class="mnote-ai-admin-provider-card-header">';
html += '<span class="mnote-ai-admin-provider-card-title">' + escapeHtml(p.name || p.id || 'Provider ' + (idx + 1)) + '</span>';
html += '<span class="mnote-ai-admin-actions-bar">' +
renderTag(p.enabled !== false ? '已连接' : '未连接', p.enabled !== false ? 'green' : 'orange') +
renderTag((Array.isArray(p.allowedModels) ? p.allowedModels.length : 0) + ' 个模型', 'blue') +
'<label class="mnote-ai-admin-toggle" title="启用 Provider"><input type="checkbox" data-field="enabled"' + (p.enabled !== false ? ' checked' : '') + disabledAttr + '><span class="mnote-ai-admin-toggle-slider"></span></label>';
if (idx > 0 && isAdmin) {
html += '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--danger mnote-ai-admin-btn--small" data-action="delete-provider" data-idx="' + idx + '">删除</button>';
}
html += '</span></div><div class="mnote-ai-admin-provider-card-body">';
html += '<div class="mnote-ai-admin-form-row">';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">Provider ID</label><input class="mnote-ai-admin-input" type="text" data-field="id" value="' + escapeHtml(p.id || '') + '"' + disabledAttr + '></div>';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">名称</label><input class="mnote-ai-admin-input" type="text" data-field="name" value="' + escapeHtml(p.name || '') + '"' + disabledAttr + '></div>';
html += '</div>';
html += '<div class="mnote-ai-admin-form-row">';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">Base URL</label><input class="mnote-ai-admin-input" type="text" data-field="baseUrl" value="' + escapeHtml(p.baseUrl || '') + '" placeholder="https://api.example.com/v1"' + disabledAttr + '></div>';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">Secret 引用</label><input class="mnote-ai-admin-input" type="text" data-field="secretRef" value="' + escapeHtml(p.secretRef || '') + '" placeholder="provider-api-key"' + disabledAttr + '><div class="mnote-ai-admin-secret-hint">引用环境变量名,前端不出现 raw key</div></div>';
html += '</div>';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">允许的模型(逗号分隔)</label><input class="mnote-ai-admin-input" type="text" data-field="allowedModels" value="' + escapeHtml(allowedModels) + '" placeholder="gpt-4o, gpt-4o-mini"' + disabledAttr + '></div>';
html += '<div class="mnote-ai-admin-form-row">';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">默认模型</label>';
var allModels = Array.isArray(p.allowedModels) ? p.allowedModels : [];
html += '<select class="mnote-ai-admin-select" data-field="defaultModel"' + disabledAttr + '>';
html += '<option value="">无</option>';
allModels.forEach(function(m) {
html += '<option value="' + escapeHtml(m) + '"' + (p.defaultModel === m ? ' selected' : '') + '>' + escapeHtml(m) + '</option>';
});
html += '</select></div>';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">Build 模型</label>';
html += '<select class="mnote-ai-admin-select" data-field="defaultBuildModel"' + disabledAttr + '>';
html += '<option value="">继承默认</option>';
allModels.forEach(function(m) {
html += '<option value="' + escapeHtml(m) + '"' + (p.defaultBuildModel === m ? ' selected' : '') + '>' + escapeHtml(m) + '</option>';
});
html += '</select></div>';
html += '</div>';
html += '<div class="mnote-ai-admin-form-row">';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">Plan 模型</label>';
html += '<select class="mnote-ai-admin-select" data-field="defaultPlanModel"' + disabledAttr + '>';
html += '<option value="">继承默认</option>';
allModels.forEach(function(m) {
html += '<option value="' + escapeHtml(m) + '"' + (p.defaultPlanModel === m ? ' selected' : '') + '>' + escapeHtml(m) + '</option>';
});
html += '</select></div>';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">Task 模型</label>';
html += '<select class="mnote-ai-admin-select" data-field="defaultTaskModel"' + disabledAttr + '>';
html += '<option value="">继承默认</option>';
allModels.forEach(function(m) {
html += '<option value="' + escapeHtml(m) + '"' + (p.defaultTaskModel === m ? ' selected' : '') + '>' + escapeHtml(m) + '</option>';
});
html += '</select></div>';
html += '</div>';
html += '<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">Failover 链(逗号分隔 provider ID</label><input class="mnote-ai-admin-input" type="text" data-field="failoverChains" value="' + escapeHtml(failover) + '" placeholder="openai, anthropic"' + disabledAttr + '></div>';
html += '</div></div>';
});
modelsContainer.innerHTML = '<div class="mnote-ai-admin-search-row"><input class="mnote-ai-admin-input" type="search" placeholder="搜索服务商..." data-ai-admin-filter="providers"></div>' + 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 = '<div class="mnote-ai-admin-empty">暂未配置工具策略</div>';
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 = '<div class="mnote-ai-admin-stat-grid" style="margin-bottom:16px;">' +
'<div class="mnote-ai-admin-stat-card">🔒 危险:' + riskGroups.dangerous + '</div>' +
'<div class="mnote-ai-admin-stat-card"> 敏感:' + riskGroups.moderate + '</div>' +
'<div class="mnote-ai-admin-stat-card">✓ 安全:' + riskGroups.safe + '</div>' +
'<div class="mnote-ai-admin-stat-card">⌁ 自定义:' + riskGroups.custom + '</div>' +
'</div>';
var search = '<div class="mnote-ai-admin-search-row"><input class="mnote-ai-admin-input" type="search" placeholder="搜索工具..." data-ai-admin-filter="tools"></div>';
var rows = tools.map(function(t, idx) {
var disabledAttr = isAdmin ? '' : ' disabled';
return '<tr>' +
'<td>' + renderTag(t.name || '-', 'blue') + '</td>' +
'<td style="font-size:12px;color:#8B8782;">' + escapeHtml(t.description || '-') + '</td>' +
'<td><select class="mnote-ai-admin-select" data-tool-idx="' + idx + '"' + disabledAttr + '>' +
'<option value="allow"' + (t.defaultPolicy === 'allow' ? ' selected' : '') + '>Allow</option>' +
'<option value="ask"' + (t.defaultPolicy === 'ask' ? ' selected' : '') + '>Ask</option>' +
'<option value="deny"' + (t.defaultPolicy === 'deny' ? ' selected' : '') + '>Deny</option>' +
'</select></td>' +
'<td>' + renderTag(t.defaultPolicy || 'allow', t.defaultPolicy === 'deny' ? 'red' : (t.defaultPolicy === 'ask' ? 'orange' : 'green')) + '</td>' +
'</tr>';
}).join('');
toolsContainer.innerHTML = stats + search + '<div class="mnote-ai-admin-table-wrap"><table class="mnote-ai-admin-table"><thead><tr><th>工具</th><th>说明</th><th>策略</th><th>当前</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
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 = '<div class="mnote-ai-admin-empty">暂未配置 Skills</div>';
return;
}
var enabledCount = skills.filter(function(s) { return s.enabled !== false; }).length;
var items = skills.map(function(s, idx) {
var disabledAttr = isAdmin ? '' : ' disabled';
return '<div class="mnote-ai-admin-skill-row" data-skill-idx="' + idx + '">' +
'<div class="mnote-ai-admin-skill-info">' +
'<div class="mnote-ai-admin-form-row">' +
'<input class="mnote-ai-admin-input" name="skillName" data-field="skill-name" data-idx="' + idx + '" value="' + escapeHtml(s.name || s.id || '') + '" placeholder="Skill 名称"' + disabledAttr + '>' +
'<input class="mnote-ai-admin-input" name="skillDescription" data-field="skill-description" data-idx="' + idx + '" value="' + escapeHtml(s.description || '') + '" placeholder="说明"' + disabledAttr + '>' +
'</div>' +
'</div>' +
'<label class="mnote-ai-admin-toggle"><input type="checkbox" name="skillEnabled" data-field="skill-enabled" data-idx="' + idx + '"' + (s.enabled !== false ? ' checked' : '') + disabledAttr + '><span class="mnote-ai-admin-toggle-slider"></span></label>' +
(isAdmin ? '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--danger mnote-ai-admin-btn--small" data-action="delete-skill" data-idx="' + idx + '">删除</button>' : '') +
'</div>';
}).join('');
skillsContainer.innerHTML =
'<div class="mnote-ai-admin-stat-grid" style="margin-bottom:12px;"><div class="mnote-ai-admin-stat-card">✓ 已启用:' + enabledCount + '/' + skills.length + '</div></div>' +
'<div class="mnote-ai-admin-search-row"><input class="mnote-ai-admin-input" type="search" placeholder="搜索技能..." data-ai-admin-filter="skills"></div>' +
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 = '<div class="mnote-ai-admin-empty">暂未配置 MCP Server</div>';
return;
}
var items = mcpServers.map(function(s, idx) {
var disabledAttr = isAdmin ? '' : ' disabled';
var secretRefsStr = Array.isArray(s.secretRefs) ? s.secretRefs.join(', ') : (s.secretRefs || '');
return '<div class="mnote-ai-admin-provider-card" data-mcp-idx="' + idx + '">' +
'<div class="mnote-ai-admin-provider-card-header">' +
'<span class="mnote-ai-admin-provider-card-title">' + escapeHtml(s.name || s.id || 'MCP ' + (idx + 1)) + '</span>' +
'<span class="mnote-ai-admin-actions-bar">' +
renderTag(s.transport || 'stdio', 'blue') +
renderTag(s.facadeOnly !== false ? 'facade-only' : 'raw', s.facadeOnly !== false ? 'green' : 'orange') +
'<label class="mnote-ai-admin-toggle"><input type="checkbox" name="mcpEnabled" data-field="mcp-enabled" data-idx="' + idx + '"' + (s.enabled !== false ? ' checked' : '') + disabledAttr + '><span class="mnote-ai-admin-toggle-slider"></span></label>' +
(isAdmin ? '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--danger mnote-ai-admin-btn--small" data-action="delete-mcp" data-idx="' + idx + '">删除</button>' : '') +
'</span></div><div class="mnote-ai-admin-provider-card-body">' +
'<div class="mnote-ai-admin-form-row">' +
'<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">名称</label><input class="mnote-ai-admin-input" name="mcpName" type="text" data-field="mcp-name" data-idx="' + idx + '" value="' + escapeHtml(s.name || '') + '"' + disabledAttr + '></div>' +
'<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">传输方式</label><select class="mnote-ai-admin-select" name="mcpTransport" data-field="mcp-transport" data-idx="' + idx + '"' + disabledAttr + '>' +
'<option value="stdio"' + (s.transport === 'stdio' ? ' selected' : '') + '>stdio</option>' +
'<option value="sse"' + (s.transport === 'sse' ? ' selected' : '') + '>SSE</option>' +
'<option value="streamable-http"' + (s.transport === 'streamable-http' ? ' selected' : '') + '>Streamable HTTP</option>' +
'</select></div>' +
'</div>' +
'<div class="mnote-ai-admin-form-row">' +
'<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">命令 (stdio)</label><input class="mnote-ai-admin-input" name="mcpCommand" type="text" data-field="mcp-command" data-idx="' + idx + '" value="' + escapeHtml(s.command || '') + '"' + disabledAttr + '></div>' +
'<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">URL (sse/http)</label><input class="mnote-ai-admin-input" type="text" data-field="mcp-url" data-idx="' + idx + '" value="' + escapeHtml(s.url || '') + '"' + disabledAttr + '></div>' +
'</div>' +
'<div class="mnote-ai-admin-form-row">' +
'<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">网络策略</label><select class="mnote-ai-admin-select" data-field="mcp-network" data-idx="' + idx + '"' + disabledAttr + '>' +
'<option value="deny-all"' + (s.networkPolicy === 'deny-all' || !s.networkPolicy ? ' selected' : '') + '>拒绝所有</option>' +
'<option value="allow-local"' + (s.networkPolicy === 'allow-local' ? ' selected' : '') + '>允许本地</option>' +
'<option value="allow-all"' + (s.networkPolicy === 'allow-all' ? ' selected' : '') + '>允许全部</option>' +
'</select></div>' +
'<div class="mnote-ai-admin-form-group"><label class="mnote-ai-admin-label">Secret 引用(逗号分隔)</label><input class="mnote-ai-admin-input" name="mcpSecretRefs" type="text" data-field="mcp-secrets" data-idx="' + idx + '" value="' + escapeHtml(secretRefsStr) + '" placeholder="env://LIGHTRAG_API_KEY"' + disabledAttr + '><div class="mnote-ai-admin-secret-hint">仅允许 env:// 或 secret:// 引用</div></div>' +
'</div>' +
'<div class="mnote-ai-admin-form-group">' +
'<label class="mnote-ai-admin-label" style="display:flex;align-items:center;gap:8px;cursor:pointer;">' +
'<input type="checkbox" data-field="mcp-facade" data-idx="' + idx + '"' + (s.facadeOnly !== false ? ' checked' : '') + disabledAttr + '> Facade-only(不暴露 raw MCP 给 AI' +
'</label>' +
'</div>' +
'</div></div>';
}).join('');
mcpContainer.innerHTML = '<div class="mnote-ai-admin-search-row"><input class="mnote-ai-admin-input" type="search" placeholder="搜索 MCP Server..." data-ai-admin-filter="mcp"></div>' + 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 = '<div class="mnote-ai-admin-empty">模型配置 API 暂不可用</div>';
if (toolsContainer) toolsContainer.innerHTML = '<div class="mnote-ai-admin-empty">工具配置 API 暂不可用</div>';
if (skillsContainer) skillsContainer.innerHTML = '<div class="mnote-ai-admin-empty">技能配置 API 暂不可用</div>';
if (mcpContainer) mcpContainer.innerHTML = '<div class="mnote-ai-admin-empty">MCP 配置 API 暂不可用</div>';
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 = '<div class="mnote-ai-admin-empty">' + escapeHtml(err.message || '加载用户失败') + '</div>';
});
}
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<String>,
#[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! {
<div
class="mnote-ai-admin"
data-testid="mnote-ai-admin-page"
data-ai-admin-role={if is_admin { "admin" } else { "user" }}
>
<style inner_html={AI_ADMIN_STYLE.to_string()}></style>
<button
type="button"
class="mnote-ai-admin-mobile-trigger"
data-action="toggle-mobile-menu"
aria-label="打开管理菜单"
>"☰"</button>
<div class="mnote-ai-admin-shell">
<aside class="mnote-ai-admin-sider">
<div class="mnote-ai-admin-brand">
<h1 class="mnote-ai-admin-brand-title">"MNote"</h1>
<span class="mnote-ai-admin-brand-subtitle">"AI 管理后台"</span>
</div>
<nav class="mnote-ai-admin-nav" aria-label="AI 管理导航">
<div class="mnote-ai-admin-nav-title">"管理项"</div>
<a href="#ai-admin-overview" data-ai-admin-nav data-nav-icon="home" class="is-active" aria-current="page">"概览"</a>
<a href="#ai-admin-users" data-ai-admin-nav data-nav-icon="users">"用户管理"</a>
<a href="#ai-admin-models" data-ai-admin-nav data-nav-icon="models">"模型配置"</a>
<a href="#ai-admin-service" data-ai-admin-nav data-nav-icon="service">"OpenHub / Pi 服务"</a>
<a href="#ai-admin-usage" data-ai-admin-nav data-nav-icon="stats">"用量统计"</a>
<a href="#ai-admin-tools" data-ai-admin-nav data-nav-icon="tools">"工具权限"</a>
<a href="#ai-admin-skills" data-ai-admin-nav data-nav-icon="skills">"技能 / MCP"</a>
<a href="#ai-admin-access" data-ai-admin-nav data-nav-icon="folder">"目录权限"</a>
<a href="#ai-admin-knowledge" data-ai-admin-nav data-nav-icon="db">"知识库"</a>
<a href="#ai-admin-channels" data-ai-admin-nav data-nav-icon="channel">"渠道管理"</a>
<a href="#ai-admin-sessions" data-ai-admin-nav data-nav-icon="stats">"会话 / 回执"</a>
<a href="#ai-admin-health" data-ai-admin-nav data-nav-icon="monitor">"系统监控"</a>
</nav>
<div class="mnote-ai-admin-sider-footer">
<a class="mnote-ai-admin-policy-link" href="/">"返回首页"</a>
</div>
</aside>
<main class="mnote-ai-admin-content">
<div class="mnote-ai-admin-breadcrumb">
<a href="/">"首页"</a>
<span>"/"</span>
<span>"管理后台"</span>
</div>
<div class="mnote-ai-admin-card-shell">
<header class="mnote-ai-admin-header">
<h1>"AI 管理"</h1>
<p>{role_text}</p>
</header>
// ════════════════════════════════════════
// 1. 概览 (Overview)
// ════════════════════════════════════════
<section id="ai-admin-overview" class="mnote-ai-admin-section mnote-ai-admin-panel is-active" data-ai-admin-panel data-testid="mnote-ai-admin-overview">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"概览"</h2>
<p class="mnote-ai-admin-section-desc">"AI 服务运行状态摘要"</p>
</div>
</div>
<div class="mnote-ai-admin-section-body">
<div class="mnote-ai-admin-grid">
<div class="mnote-ai-admin-card">
<span class="mnote-ai-admin-card-label">"AI Providers"</span>
<strong class="mnote-ai-admin-card-value" data-ai-admin-overview-providers>"..."</strong>
<span class="mnote-ai-admin-card-desc">"已配置 provider 数量"</span>
</div>
<div class="mnote-ai-admin-card">
<span class="mnote-ai-admin-card-label">"可用模型"</span>
<strong class="mnote-ai-admin-card-value" data-ai-admin-overview-models>"..."</strong>
<span class="mnote-ai-admin-card-desc">"模型配置数"</span>
</div>
<div class="mnote-ai-admin-card">
<span class="mnote-ai-admin-card-label">"已授权文件夹"</span>
<strong class="mnote-ai-admin-card-value" data-ai-admin-overview-scopes>"..."</strong>
<span class="mnote-ai-admin-card-desc">"AI 可访问的本地文件夹数"</span>
</div>
<div class="mnote-ai-admin-card">
<span class="mnote-ai-admin-card-label">"工作区"</span>
<strong class="mnote-ai-admin-card-value">{workspace_name.clone()}</strong>
<span class="mnote-ai-admin-card-desc">"当前上下文"</span>
</div>
</div>
</div>
</section>
<section id="ai-admin-users" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-users">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"用户管理"</h2>
<p class="mnote-ai-admin-section-desc">"参考 OpenHub:用户、逐用户模型/工具/目录授权"</p>
</div>
<a href={access_policy_href} class="mnote-ai-admin-policy-link">"目录授权"</a>
</div>
<div class="mnote-ai-admin-section-body">
{if is_admin {
view! {
<div class="mnote-ai-admin-users-list" data-ai-admin-users-list>
<div class="mnote-ai-admin-skeleton">"加载用户..."</div>
</div>
}.into_any()
} else {
view! {
<div class="mnote-ai-admin-empty">"用户模式仅显示自己的 effective AI 设置。"</div>
}.into_any()
}}
</div>
</section>
// ════════════════════════════════════════
// 2. 模型配置(内联编辑)
// ════════════════════════════════════════
<section id="ai-admin-models" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-providers">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"模型配置"</h2>
<p class="mnote-ai-admin-section-desc">"AI 服务提供商与模型配置"</p>
</div>
{if is_admin {
view! {
<div class="mnote-ai-admin-actions-bar">
<button type="button" class="mnote-ai-admin-btn" data-action="add-provider">"+ 添加 Provider"</button>
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-action="save-models">"保存配置"</button>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
<div class="mnote-ai-admin-section-body">
<div data-ai-admin-models-save-status class="mnote-ai-admin-save-status"></div>
<div data-ai-admin-models-container>
<div class="mnote-ai-admin-skeleton">"加载模型配置..."</div>
</div>
<details style="margin-top: 12px;">
<summary style="font-size: 12px; color: #8B8782; cursor: pointer;">"查看 effective JSON"</summary>
<pre class="mnote-ai-admin-json" data-ai-admin-effective-json>{""}</pre>
</details>
</div>
</section>
<section id="ai-admin-service" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-service">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"OpenHub / Pi 服务"</h2>
<p class="mnote-ai-admin-section-desc">"OpenHub 仍是默认 Page AIPi Lab 是独立实验入口"</p>
</div>
<a href="/page-ai/openhub/admin" class="mnote-ai-admin-policy-link">"打开 OpenHub Admin"</a>
</div>
<div class="mnote-ai-admin-section-body">
<div data-ai-admin-service-panel>
<div class="mnote-ai-admin-skeleton">"加载 OpenHub / Pi 服务状态..."</div>
</div>
</div>
</section>
<section id="ai-admin-usage" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-usage">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"用量统计"</h2>
<p class="mnote-ai-admin-section-desc">"先展示 control-plane 中已有的会话、回执和补丁计数"</p>
</div>
</div>
<div class="mnote-ai-admin-section-body" data-ai-admin-usage-summary>
<div class="mnote-ai-admin-skeleton">"加载用量统计..."</div>
</div>
</section>
// ════════════════════════════════════════
// 3. 工具权限(内联编辑)
// ════════════════════════════════════════
<section id="ai-admin-tools" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-tools">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"工具权限"</h2>
<p class="mnote-ai-admin-section-desc">"每个工具的 allow / ask / deny 策略"</p>
</div>
{if is_admin {
view! {
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-action="save-tools">"保存策略"</button>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
<div class="mnote-ai-admin-section-body">
<div data-ai-admin-tools-save-status class="mnote-ai-admin-save-status"></div>
<div data-ai-admin-tools-container>
<div class="mnote-ai-admin-skeleton">"加载工具权限..."</div>
</div>
</div>
</section>
// ════════════════════════════════════════
// 4. 技能 / MCP(内联编辑)
// ════════════════════════════════════════
<section id="ai-admin-skills" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-skills">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"技能 / MCP"</h2>
<p class="mnote-ai-admin-section-desc">"Skills 启停管理 + MCP Server 配置(facade-only、transport、命令/URL、网络策略、secret 引用)"</p>
</div>
{if is_admin {
view! {
<div class="mnote-ai-admin-actions-bar">
<button type="button" class="mnote-ai-admin-btn" data-action="add-skill">"+ 添加 Skill"</button>
<button type="button" class="mnote-ai-admin-btn" data-action="add-mcp">"+ 添加 MCP"</button>
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-action="save-skills">"保存配置"</button>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
<div class="mnote-ai-admin-section-body">
<div data-ai-admin-skills-save-status class="mnote-ai-admin-save-status"></div>
<div class="mnote-ai-admin-mcp-subsection">
<h3>"Skills"</h3>
<div data-ai-admin-skills-container>
<div class="mnote-ai-admin-skeleton">"加载 Skills..."</div>
</div>
</div>
<div class="mnote-ai-admin-mcp-subsection">
<h3>"MCP Servers"</h3>
<p style="font-size:12px;color:#8B8782;margin:0 0 8px;">"MCP 通过 MNote facade 管控,不出现在 AI 原始 tool list 中。前端不出现 raw API key。"</p>
<div data-ai-admin-mcp-container>
<div class="mnote-ai-admin-skeleton">"加载 MCP Server 配置..."</div>
</div>
</div>
</div>
</section>
// ════════════════════════════════════════
// 5. Access Scopes(只读)
// ════════════════════════════════════════
<section id="ai-admin-access" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-access-scopes">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"Access Scopes"</h2>
<p class="mnote-ai-admin-section-desc">"文件夹授权同时用于 MNote 与 AI"</p>
</div>
<a
href={access_policy_href}
class="mnote-ai-admin-policy-link"
data-testid="mnote-ai-admin-access-policy-link"
>{access_policy_label}</a>
</div>
<div class="mnote-ai-admin-section-body">
<p style="font-size: 12px; color: #8B8782; margin: 0 0 12px;">
"当前 AI 可以访问以下已授权的本地文件夹。如需修改授权,请使用上方的授权管理入口。"
</p>
<div data-ai-admin-access-scopes-list>
<div class="mnote-ai-admin-skeleton">"加载 access scopes..."</div>
</div>
<div data-ai-admin-access-scopes-message style="font-size: 12px; color: #8B8782; margin-top: 8px;"></div>
<details style="margin-top: 12px;">
<summary style="font-size: 12px; color: #8B8782; cursor: pointer;">"查看 access-scopes JSON"</summary>
<pre class="mnote-ai-admin-json" data-ai-admin-access-scopes-json>{""}</pre>
</details>
</div>
</section>
<section id="ai-admin-knowledge" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-knowledge">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"知识库"</h2>
<p class="mnote-ai-admin-section-desc">"LightRAG 是唯一默认 knowledge provider"</p>
</div>
</div>
<div class="mnote-ai-admin-section-body">
<div data-ai-admin-knowledge-panel>
<div class="mnote-ai-admin-skeleton">"加载 LightRAG 知识库状态..."</div>
</div>
</div>
</section>
<section id="ai-admin-channels" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-channels">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"渠道管理"</h2>
<p class="mnote-ai-admin-section-desc">"OpenHub 的渠道管理在 MNote 中收敛为 provider/model policy"</p>
</div>
</div>
<div class="mnote-ai-admin-section-body">
<div data-ai-admin-channels-panel>
<div class="mnote-ai-admin-skeleton">"加载渠道配置..."</div>
</div>
</div>
</section>
// ════════════════════════════════════════
// 6. Sessions & Receipts
// ════════════════════════════════════════
<section id="ai-admin-sessions" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-sessions">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"Sessions & Receipts"</h2>
<p class="mnote-ai-admin-section-desc">"AI 会话记录与用量统计"</p>
</div>
</div>
<div class="mnote-ai-admin-section-body">
<p class="mnote-ai-admin-section-desc" data-ai-admin-receipts-summary>
"加载 control-plane 会话与回执..."
</p>
<h3 style="font-size: 13px; margin: 16px 0 8px;">"Pi Lab 会话"</h3>
<table class="mnote-ai-admin-table">
<thead><tr><th>"会话"</th><th>"模型"</th><th>"状态"</th><th>"更新时间"</th></tr></thead>
<tbody data-ai-admin-sessions-body>
<tr><td colspan="4" class="mnote-ai-admin-skeleton">"加载会话..."</td></tr>
</tbody>
</table>
<h3 style="font-size: 13px; margin: 20px 0 8px;">"工具回执"</h3>
<table class="mnote-ai-admin-table">
<thead><tr><th>"工具"</th><th>"结果"</th><th>"摘要 / 拒绝原因"</th><th>"时间"</th></tr></thead>
<tbody data-ai-admin-receipts-body>
<tr><td colspan="4" class="mnote-ai-admin-skeleton">"加载回执..."</td></tr>
</tbody>
</table>
</div>
</section>
<section id="ai-admin-health" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-health">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"系统监控"</h2>
<p class="mnote-ai-admin-section-desc">"以 AI 相关服务健康为主,不复制 OpenHub 进程管理"</p>
</div>
</div>
<div class="mnote-ai-admin-section-body">
<div class="mnote-ai-admin-subgrid" data-ai-admin-health-grid>
<div class="mnote-ai-admin-skeleton">"加载健康状态..."</div>
</div>
</div>
</section>
</div>
</main>
</div>
<div class="mnote-ai-admin-drawer" data-ai-admin-user-drawer>
<div class="mnote-ai-admin-drawer-backdrop" data-ai-admin-drawer-backdrop></div>
<div class="mnote-ai-admin-drawer-panel" role="dialog" aria-modal="true" aria-label="用户 AI 配置">
<div class="mnote-ai-admin-drawer-head">
<div>
<h3 data-ai-admin-user-drawer-title>"用户配置"</h3>
<p class="mnote-ai-admin-section-desc" data-ai-admin-user-drawer-subtitle>
"逐用户模型 / 工具 / Skills / MCP / 目录权限"
</p>
</div>
<button type="button" class="mnote-ai-admin-icon-btn" data-action="close-user-drawer" aria-label="关闭">"×"</button>
</div>
<div class="mnote-ai-admin-drawer-body">
<div data-ai-admin-user-save-status class="mnote-ai-admin-save-status"></div>
<div data-ai-admin-user-settings>
<div class="mnote-ai-admin-empty">"请选择用户"</div>
</div>
</div>
</div>
</div>
<script id="__MNOTE_AI_ADMIN_CONFIG__" type="application/json" inner_html={config_json}></script>
<script inner_html={AI_ADMIN_SCRIPT.to_string()}></script>
</div>
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ssr::render_view;
#[test]
fn ai_admin_page_renders_all_panels() {
let html = render_view(view! {
<AiManagementPage
workspace_name={"测试工作区".to_string()}
is_admin=true
/>
});
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 &amp; 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! {
<AiManagementPage
workspace_name={"用户空间".to_string()}
is_admin=false
/>
});
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! {
<AiManagementPage
workspace_name={"管理空间".to_string()}
is_admin=true
/>
});
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! {
<AiManagementPage
workspace_name={"测试".to_string()}
is_admin=true
/>
});
// 面板容器 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! {
<AiManagementPage
workspace_name={"测试".to_string()}
is_admin=true
/>
});
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! {
<AiManagementPage is_admin=true />
});
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! {
<AiManagementPage />
});
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'"));
}
}