//! MNOTE AI 管理页面组件
//!
//! 可供 /admin/ai 和 /user/ai 使用的 Leptos SSR 管理页面。
//! 包含 Overview、Models、Tools、Skills/MCP、Access Scopes 等面板。
//! 用户模式下隐藏管理写操作。写管理使用 GET/PUT /api/ai-admin/settings。
//! 模型/工具/技能面板通过 JS 渲染内联编辑表单,保存后 PUT 全量配置。
use leptos::prelude::*;
/// AI 管理页面内嵌 scoped 样式
const AI_ADMIN_STYLE: &str = r#"
.mnote-ai-admin {
min-height: 100vh;
background: #f5f7fa;
color: #1f1f1f;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.mnote-ai-admin-mobile-trigger {
display: none;
position: fixed;
top: 16px;
left: 16px;
z-index: 1001;
width: 34px;
height: 34px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #ffffff;
color: #1890ff;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
cursor: pointer;
}
.mnote-ai-admin-shell {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
min-height: 100vh;
}
.mnote-ai-admin-sider {
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
background: #ffffff;
border-right: 1px solid #f0f0f0;
}
.mnote-ai-admin-brand {
padding: 20px 16px 12px;
border-bottom: 1px solid #f0f0f0;
}
.mnote-ai-admin-brand-title {
margin: 0;
color: #1890ff;
font-size: 16px;
font-weight: 650;
line-height: 1.2;
}
.mnote-ai-admin-brand-subtitle {
display: block;
margin-top: 3px;
color: #8c8c8c;
font-size: 11px;
}
.mnote-ai-admin-nav {
padding: 8px 0;
}
.mnote-ai-admin-nav-title {
padding: 10px 16px 6px;
color: #bfbfbf;
font-size: 11px;
font-weight: 600;
}
.mnote-ai-admin-nav a {
position: relative;
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 16px 0 24px;
color: rgba(0,0,0,0.78);
font-size: 14px;
text-decoration: none;
}
.mnote-ai-admin-nav a::before {
width: 16px;
color: #8c8c8c;
font-size: 15px;
text-align: center;
}
.mnote-ai-admin-nav a[data-nav-icon="home"]::before { content: "⌂"; }
.mnote-ai-admin-nav a[data-nav-icon="users"]::before { content: "👤"; }
.mnote-ai-admin-nav a[data-nav-icon="models"]::before { content: "⚙"; }
.mnote-ai-admin-nav a[data-nav-icon="service"]::before { content: "☁"; }
.mnote-ai-admin-nav a[data-nav-icon="stats"]::before { content: "▦"; }
.mnote-ai-admin-nav a[data-nav-icon="tools"]::before { content: "🛡"; }
.mnote-ai-admin-nav a[data-nav-icon="skills"]::before { content: "⚡"; }
.mnote-ai-admin-nav a[data-nav-icon="folder"]::before { content: "📁"; }
.mnote-ai-admin-nav a[data-nav-icon="db"]::before { content: "▣"; }
.mnote-ai-admin-nav a[data-nav-icon="channel"]::before { content: "◇"; }
.mnote-ai-admin-nav a[data-nav-icon="monitor"]::before { content: "◉"; }
.mnote-ai-admin-nav a:hover {
color: #1890ff;
background: #e6f7ff;
}
.mnote-ai-admin-nav a.is-active {
color: #1890ff;
background: #e6f7ff;
font-weight: 500;
}
.mnote-ai-admin-nav a.is-active::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 3px;
background: #1890ff;
}
.mnote-ai-admin-sider-footer {
position: sticky;
bottom: 0;
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
background: #ffffff;
}
.mnote-ai-admin-content {
min-width: 0;
padding: 24px 32px;
}
.mnote-ai-admin-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
.mnote-ai-admin-breadcrumb a {
color: rgba(0,0,0,0.45);
text-decoration: none;
}
.mnote-ai-admin-breadcrumb a:hover {
color: #1890ff;
}
.mnote-ai-admin-card-shell {
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
}
.mnote-ai-admin-header {
padding: 24px 24px 0;
margin-bottom: 0;
}
.mnote-ai-admin-header h1 {
margin: 0 0 4px;
color: rgba(0,0,0,0.88);
font-size: 20px;
font-weight: 650;
line-height: 1.25;
}
.mnote-ai-admin-header p {
margin: 0;
color: rgba(0,0,0,0.45);
font-size: 13px;
}
.mnote-ai-admin-section {
display: none;
padding: 24px;
}
.mnote-ai-admin-section.is-active {
display: block;
}
.mnote-ai-admin-panel {
display: none;
}
.mnote-ai-admin-panel.is-active {
display: block;
}
.mnote-ai-admin-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.mnote-ai-admin-section-header h2 {
margin: 0;
color: rgba(0,0,0,0.88);
font-size: 18px;
font-weight: 650;
line-height: 1.35;
}
.mnote-ai-admin-section-header .mnote-ai-admin-section-desc {
margin: 4px 0 0;
color: rgba(0,0,0,0.45);
font-size: 13px;
}
.mnote-ai-admin-section-body {
min-width: 0;
}
.mnote-ai-admin-grid,
.mnote-ai-admin-subgrid,
.mnote-ai-admin-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.mnote-ai-admin-card,
.mnote-ai-admin-mini-panel,
.mnote-ai-admin-stat-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #ffffff;
padding: 12px 16px;
}
.mnote-ai-admin-card {
min-height: 78px;
}
.mnote-ai-admin-card-label,
.mnote-ai-admin-mini-desc,
.mnote-ai-admin-card-desc {
color: rgba(0,0,0,0.45);
font-size: 12px;
}
.mnote-ai-admin-card-value {
display: block;
margin: 6px 0 3px;
color: rgba(0,0,0,0.88);
font-size: 24px;
font-weight: 650;
line-height: 1.1;
}
.mnote-ai-admin-mini-title {
margin: 0 0 6px;
color: rgba(0,0,0,0.88);
font-size: 14px;
font-weight: 600;
}
.mnote-ai-admin-card-title-row,
.mnote-ai-admin-card-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.mnote-ai-admin-card-title-row {
color: rgba(0,0,0,0.88);
font-size: 14px;
font-weight: 600;
}
.mnote-ai-admin-card-meta-row {
margin: 8px 0 0;
color: rgba(0,0,0,0.45);
font-size: 12px;
}
.mnote-ai-admin-admin-list,
.mnote-ai-admin-stack {
display: grid;
gap: 12px;
}
.mnote-ai-admin-admin-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #ffffff;
}
.mnote-ai-admin-admin-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.mnote-ai-admin-admin-card-body {
padding: 14px 16px 16px;
}
.mnote-ai-admin-admin-card h3 {
margin: 0;
font-size: 14px;
}
.mnote-ai-admin-kv-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.mnote-ai-admin-kv {
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 9px 10px;
background: #ffffff;
}
.mnote-ai-admin-kv span {
display: block;
color: rgba(0,0,0,0.45);
font-size: 12px;
}
.mnote-ai-admin-kv strong {
display: block;
margin-top: 4px;
color: rgba(0,0,0,0.88);
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
}
.mnote-ai-admin-table-wrap {
width: 100%;
overflow: auto;
}
.mnote-ai-admin-table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
.mnote-ai-admin-table th {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
color: rgba(0,0,0,0.65);
font-size: 13px;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
.mnote-ai-admin-table td {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
color: rgba(0,0,0,0.78);
font-size: 13px;
vertical-align: middle;
}
.mnote-ai-admin-table tr:last-child td {
border-bottom: 0;
}
.mnote-ai-admin-table tr:hover td {
background: #fafafa;
}
.mnote-ai-admin-badge,
.mnote-ai-admin-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fafafa;
color: rgba(0,0,0,0.65);
font-size: 12px;
line-height: 20px;
white-space: nowrap;
}
.mnote-ai-admin-badge--active,
.mnote-ai-admin-tag--green {
border-color: #b7eb8f;
background: #f6ffed;
color: #389e0d;
}
.mnote-ai-admin-badge--default,
.mnote-ai-admin-tag--blue {
border-color: #91caff;
background: #e6f4ff;
color: #1677ff;
}
.mnote-ai-admin-tag--red {
border-color: #ffa39e;
background: #fff1f0;
color: #cf1322;
}
.mnote-ai-admin-tag--orange {
border-color: #ffd591;
background: #fff7e6;
color: #d46b08;
}
.mnote-ai-admin-empty,
.mnote-ai-admin-placeholder,
.mnote-ai-admin-skeleton {
padding: 28px 0;
color: rgba(0,0,0,0.45);
font-size: 13px;
text-align: center;
}
.mnote-ai-admin-json {
max-height: 300px;
margin-top: 8px;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fafafa;
padding: 12px;
color: rgba(0,0,0,0.72);
font: 12px/1.55 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mnote-ai-admin-link,
.mnote-ai-admin-policy-link {
color: #1677ff;
text-decoration: none;
}
.mnote-ai-admin-policy-link,
.mnote-ai-admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #ffffff;
color: rgba(0,0,0,0.88);
font-size: 14px;
font-weight: 400;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.mnote-ai-admin-policy-link:hover,
.mnote-ai-admin-btn:hover {
border-color: #4096ff;
color: #1677ff;
}
.mnote-ai-admin-btn--primary {
border-color: #1677ff;
background: #1677ff;
color: #ffffff;
}
.mnote-ai-admin-btn--primary:hover {
border-color: #4096ff;
background: #4096ff;
color: #ffffff;
}
.mnote-ai-admin-btn--danger {
color: #cf1322;
}
.mnote-ai-admin-btn--small {
height: 24px;
padding: 0 7px;
font-size: 12px;
}
.mnote-ai-admin-icon-actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mnote-ai-admin-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 4px;
background: transparent;
color: rgba(0,0,0,0.55);
cursor: pointer;
}
.mnote-ai-admin-icon-btn:hover {
background: #e6f4ff;
color: #1677ff;
}
.mnote-ai-admin-icon-btn.is-danger:hover {
background: #fff1f0;
color: #cf1322;
}
.mnote-ai-admin-input,
.mnote-ai-admin-select {
width: 100%;
min-height: 32px;
box-sizing: border-box;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #ffffff;
color: rgba(0,0,0,0.88);
font-size: 14px;
}
.mnote-ai-admin-input {
padding: 4px 11px;
}
.mnote-ai-admin-select {
padding: 4px 8px;
}
.mnote-ai-admin-input:focus,
.mnote-ai-admin-select:focus {
outline: none;
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5,145,255,0.1);
}
.mnote-ai-admin-input:disabled,
.mnote-ai-admin-select:disabled {
background: #f5f5f5;
color: rgba(0,0,0,0.25);
cursor: not-allowed;
}
.mnote-ai-admin-label {
display: block;
margin-bottom: 5px;
color: rgba(0,0,0,0.65);
font-size: 13px;
}
.mnote-ai-admin-form-group {
margin-bottom: 12px;
}
.mnote-ai-admin-form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.mnote-ai-admin-actions-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.mnote-ai-admin-search-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.mnote-ai-admin-search-row .mnote-ai-admin-input {
max-width: 260px;
}
.mnote-ai-admin-provider-card {
margin-bottom: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #ffffff;
}
.mnote-ai-admin-provider-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.mnote-ai-admin-provider-card-title {
font-size: 14px;
font-weight: 600;
}
.mnote-ai-admin-provider-card-body {
padding: 16px;
}
.mnote-ai-admin-collapse {
margin-bottom: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.mnote-ai-admin-collapse > summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fafafa;
cursor: pointer;
font-weight: 600;
}
.mnote-ai-admin-collapse-body {
padding: 12px 16px;
}
.mnote-ai-admin-save-status {
display: none;
margin-bottom: 12px;
padding: 9px 12px;
border-radius: 6px;
font-size: 13px;
}
.mnote-ai-admin-save-status--success {
display: block;
border: 1px solid #b7eb8f;
background: #f6ffed;
color: #389e0d;
}
.mnote-ai-admin-save-status--error {
display: block;
border: 1px solid #ffa39e;
background: #fff1f0;
color: #cf1322;
}
.mnote-ai-admin-toggle {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
flex-shrink: 0;
}
.mnote-ai-admin-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.mnote-ai-admin-toggle-slider {
position: absolute;
inset: 0;
border-radius: 20px;
background: #d9d9d9;
cursor: pointer;
transition: background 0.2s;
}
.mnote-ai-admin-toggle-slider::before {
content: "";
position: absolute;
left: 2px;
bottom: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffffff;
transition: transform 0.2s;
}
.mnote-ai-admin-toggle input:checked + .mnote-ai-admin-toggle-slider {
background: #1677ff;
}
.mnote-ai-admin-toggle input:checked + .mnote-ai-admin-toggle-slider::before {
transform: translateX(16px);
}
.mnote-ai-admin-secret-hint {
margin-top: 3px;
color: rgba(0,0,0,0.35);
font-size: 12px;
}
.mnote-ai-admin-skill-row,
.mnote-ai-admin-user-option,
.mnote-ai-admin-user-policy-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mnote-ai-admin-skill-row {
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.mnote-ai-admin-skill-row:last-child {
border-bottom: 0;
}
.mnote-ai-admin-skill-info {
min-width: 0;
flex: 1;
}
.mnote-ai-admin-user-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.mnote-ai-admin-user-editor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.mnote-ai-admin-user-editor-head h3 {
margin: 0;
font-size: 16px;
}
.mnote-ai-admin-user-editor-head p {
margin: 4px 0 0;
color: rgba(0,0,0,0.45);
font-size: 12px;
}
.mnote-ai-admin-user-tabbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.mnote-ai-admin-user-option {
justify-content: flex-start;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 8px 10px;
}
.mnote-ai-admin-user-option span,
.mnote-ai-admin-user-policy-row > span {
display: grid;
gap: 2px;
min-width: 0;
}
.mnote-ai-admin-user-option small,
.mnote-ai-admin-user-policy-row small {
color: rgba(0,0,0,0.45);
font-size: 12px;
}
.mnote-ai-admin-user-section {
padding: 14px 0;
border-bottom: 1px solid #f0f0f0;
}
.mnote-ai-admin-user-section h3 {
margin: 0 0 10px;
font-size: 14px;
}
.mnote-ai-admin-user-section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.mnote-ai-admin-user-section-title h3 {
margin: 0;
}
.mnote-ai-admin-permission-group {
margin-bottom: 14px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.mnote-ai-admin-permission-group-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.mnote-ai-admin-permission-group-head strong {
font-size: 13px;
}
.mnote-ai-admin-permission-group-body {
padding: 8px 12px;
}
.mnote-ai-admin-permission-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #fafafa;
}
.mnote-ai-admin-permission-row:last-child {
border-bottom: 0;
}
.mnote-ai-admin-permission-row-main {
min-width: 0;
flex: 1;
}
.mnote-ai-admin-permission-row-main strong {
display: block;
font-size: 13px;
overflow-wrap: anywhere;
}
.mnote-ai-admin-permission-row-main small {
color: rgba(0,0,0,0.45);
font-size: 12px;
}
.mnote-ai-admin-permission-row-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.mnote-ai-admin-subsection-card {
margin-bottom: 14px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #ffffff;
}
.mnote-ai-admin-subsection-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.mnote-ai-admin-subsection-card-head h3 {
margin: 0;
font-size: 14px;
}
.mnote-ai-admin-subsection-card-body {
padding: 14px 16px 16px;
}
.mnote-ai-admin-access-scope-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.mnote-ai-admin-access-scope-path {
color: rgba(0,0,0,0.88);
font-size: 13px;
font-weight: 500;
overflow-wrap: anywhere;
}
.mnote-ai-admin-access-scope-meta {
color: rgba(0,0,0,0.45);
font-size: 12px;
}
.mnote-ai-admin-drawer {
position: fixed;
inset: 0;
z-index: 1100;
display: none;
}
.mnote-ai-admin-drawer.is-open {
display: block;
}
.mnote-ai-admin-drawer-backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.35);
}
.mnote-ai-admin-drawer-panel {
position: absolute;
top: 0;
right: 0;
width: min(720px, 100vw);
height: 100%;
overflow: auto;
background: #ffffff;
box-shadow: -4px 0 16px rgba(0,0,0,0.12);
}
.mnote-ai-admin-drawer-head {
position: sticky;
top: 0;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #ffffff;
}
.mnote-ai-admin-drawer-head h3 {
margin: 0;
font-size: 16px;
}
.mnote-ai-admin-drawer-body {
padding: 18px 20px 24px;
}
@media (max-width: 768px) {
.mnote-ai-admin-mobile-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
}
.mnote-ai-admin-shell {
grid-template-columns: 1fr;
}
.mnote-ai-admin-sider {
position: fixed;
inset: 0 auto 0 0;
z-index: 1000;
width: 220px;
transform: translateX(-100%);
transition: transform 0.18s ease;
box-shadow: 2px 0 12px rgba(0,0,0,0.12);
}
.mnote-ai-admin.is-mobile-menu-open .mnote-ai-admin-sider {
transform: translateX(0);
}
.mnote-ai-admin-content {
padding: 16px;
padding-top: 58px;
}
.mnote-ai-admin-section,
.mnote-ai-admin-header {
padding-left: 16px;
padding-right: 16px;
}
.mnote-ai-admin-section-header,
.mnote-ai-admin-search-row,
.mnote-ai-admin-access-scope-row {
flex-direction: column;
align-items: flex-start;
}
.mnote-ai-admin-form-row {
grid-template-columns: 1fr;
}
}
"#;
/// AI 管理页面内嵌脚本
///
/// 从后端 API 获取 effective providers/models、access scopes 和 config,
/// 填充到页面相应区域。模型/工具/技能 MCP 面板通过 loadConfig/saveConfig 提供内联编辑。
const AI_ADMIN_SCRIPT: &str = r#"
(function () {
function initAiAdminPage(scope) {
var root = (scope || document).querySelector('[data-testid="mnote-ai-admin-page"]');
if (!root) return;
if (root.getAttribute('data-ai-admin-ready') === 'true') return;
root.setAttribute('data-ai-admin-ready', 'true');
var pageConfig = (function () {
var node = root.querySelector('#__MNOTE_AI_ADMIN_CONFIG__');
try { return JSON.parse(node ? node.textContent || '{}' : '{}'); } catch (_) { return {}; }
})();
var isAdmin = pageConfig.isAdmin === true;
var overviewProvidersEl = root.querySelector('[data-ai-admin-overview-providers]');
var overviewModelsEl = root.querySelector('[data-ai-admin-overview-models]');
var overviewScopesEl = root.querySelector('[data-ai-admin-overview-scopes]');
var providerTableBody = root.querySelector('[data-ai-admin-providers-body]');
var modelPolicyBody = root.querySelector('[data-ai-admin-model-policy-body]');
var toolPolicyBody = root.querySelector('[data-ai-admin-tool-policy-body]');
var usageSummary = root.querySelector('[data-ai-admin-usage-summary]');
var healthGrid = root.querySelector('[data-ai-admin-health-grid]');
var servicePanel = root.querySelector('[data-ai-admin-service-panel]');
var channelsPanel = root.querySelector('[data-ai-admin-channels-panel]');
var knowledgePanel = root.querySelector('[data-ai-admin-knowledge-panel]');
var accessScopesList = root.querySelector('[data-ai-admin-access-scopes-list]');
var accessScopesJson = root.querySelector('[data-ai-admin-access-scopes-json]');
var accessScopesMessage = root.querySelector('[data-ai-admin-access-scopes-message]');
var effectiveJson = root.querySelector('[data-ai-admin-effective-json]');
var sessionsBody = root.querySelector('[data-ai-admin-sessions-body]');
var receiptsBody = root.querySelector('[data-ai-admin-receipts-body]');
var receiptsSummary = root.querySelector('[data-ai-admin-receipts-summary]');
// ── Config 面板容器 ──
var modelsContainer = root.querySelector('[data-ai-admin-models-container]');
var toolsContainer = root.querySelector('[data-ai-admin-tools-container]');
var skillsContainer = root.querySelector('[data-ai-admin-skills-container]');
var mcpContainer = root.querySelector('[data-ai-admin-mcp-container]');
var modelsSaveStatus = root.querySelector('[data-ai-admin-models-save-status]');
var toolsSaveStatus = root.querySelector('[data-ai-admin-tools-save-status]');
var skillsSaveStatus = root.querySelector('[data-ai-admin-skills-save-status]');
var usersList = root.querySelector('[data-ai-admin-users-list]');
var userSettingsEditor = root.querySelector('[data-ai-admin-user-settings]');
var userSettingsStatus = root.querySelector('[data-ai-admin-user-save-status]');
var mobileTrigger = root.querySelector('[data-action="toggle-mobile-menu"]');
var drawer = root.querySelector('[data-ai-admin-user-drawer]');
var drawerTitle = root.querySelector('[data-ai-admin-user-drawer-title]');
var drawerSubtitle = root.querySelector('[data-ai-admin-user-drawer-subtitle]');
var selectedUserId = '';
var selectedUserSettings = null;
var selectedUserPanel = 'models';
function setActivePanel(panelId, replaceHash) {
var targetId = panelId || (location.hash || '').replace(/^#/, '') || 'ai-admin-overview';
var panels = root.querySelectorAll('[data-ai-admin-panel]');
var found = false;
panels.forEach(function (panel) {
var active = panel.id === targetId;
panel.classList.toggle('is-active', active);
panel.hidden = !active;
if (active) found = true;
});
if (!found && targetId !== 'ai-admin-overview') {
setActivePanel('ai-admin-overview', true);
return;
}
root.querySelectorAll('[data-ai-admin-nav]').forEach(function (item) {
var active = item.getAttribute('href') === '#' + targetId;
item.classList.toggle('is-active', active);
if (active) item.setAttribute('aria-current', 'page');
else item.removeAttribute('aria-current');
});
root.classList.remove('is-mobile-menu-open');
if (replaceHash && history.replaceState) {
history.replaceState(null, '', '#' + targetId);
}
}
root.querySelectorAll('[data-ai-admin-nav]').forEach(function (item) {
item.addEventListener('click', function (event) {
event.preventDefault();
setActivePanel((item.getAttribute('href') || '').replace(/^#/, ''), true);
});
});
window.addEventListener('hashchange', function () { setActivePanel(); });
setActivePanel();
if (mobileTrigger) {
mobileTrigger.addEventListener('click', function () {
root.classList.toggle('is-mobile-menu-open');
});
}
root.addEventListener('click', function (event) {
var closeTarget = event.target && event.target.closest ? event.target.closest('[data-action="close-user-drawer"], [data-ai-admin-drawer-backdrop]') : null;
if (closeTarget && drawer) drawer.classList.remove('is-open');
});
function setText(node, value) {
if (!node) return;
node.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
async function requestJson(url, options) {
var response = await fetch(url, {
credentials: 'include',
headers: { 'content-type': 'application/json' },
...options,
});
var payload = await response.json().catch(function () { return {}; });
if (!response.ok && !payload.ok) {
throw new Error((payload && payload.message) || ('请求失败: ' + response.status));
}
return payload;
}
function renderBadge(text, className) {
return '' + escapeHtml(text) + '';
}
function renderTag(text, tone) {
return '' + escapeHtml(text) + '';
}
function renderKv(label, value) {
return '
' + escapeHtml(label) + '' + escapeHtml(value == null || value === '' ? '-' : value) + '
';
}
function actionLabel(action) {
if (action === 'deny') return '拒绝';
if (action === 'ask') return '审批';
return '允许';
}
function actionTone(action) {
if (action === 'deny') return 'red';
if (action === 'ask') return 'orange';
return 'green';
}
function groupBy(items, keyFn) {
return (items || []).reduce(function(acc, item) {
var key = keyFn(item) || '其他';
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
}
function renderPermissionGroup(title, countLabel, bodyHtml) {
return '' +
'
' + escapeHtml(title) + '' + renderTag(countLabel, 'blue') + '
' +
'
' + (bodyHtml || '
暂无数据
') + '
' +
'
';
}
function renderSelectablePermissionRow(options) {
var select = options.selectHtml || '';
return '' +
'
' + escapeHtml(options.title || '-') + '' + escapeHtml(options.subtitle || '') + '
' +
'
' + (options.metaHtml || '') + select + (options.resetHtml || '') + '
' +
'
';
}
function normalizeModelRef(provider, modelId) {
var p = provider || 'omniroute';
var m = modelId || 'freefirst';
if (String(m).indexOf(p + '/') === 0) return String(m);
return p + '/' + m;
}
function renderServicePanels(piStatus, effectivePayload) {
var providers = effectivePayload && Array.isArray(effectivePayload.providers) ? effectivePayload.providers : [];
var openhubStatus = '默认入口保留';
var defaultModel = effectivePayload && (effectivePayload.defaultModel || effectivePayload.default_model || '');
var piDefaultModel = piStatus
? normalizeModelRef(piStatus.defaultModelProvider || piStatus.default_model_provider, piStatus.defaultModelId || piStatus.default_model_id)
: 'omniroute/freefirst';
var piRuntime = piStatus ? (piStatus.runtimeMode || piStatus.runtime_mode || '-') : '-';
var piRunning = piStatus && piStatus.running === true;
if (servicePanel) {
servicePanel.innerHTML =
'' +
'
' +
'
' +
'
' +
'
' +
renderKv('定位', '默认 Page AI 主线') +
renderKv('Provider/模型真相', 'OpenHub 保持独立,不被 Pi Lab 替换') +
renderKv('MNote 侧入口', '/page-ai/openhub/ai') +
renderKv('管理入口', '/page-ai/openhub/admin') +
'
' +
'
' +
'
' +
'
' +
'
Pi Lab
' +
renderTag(piRunning ? '运行中' : '待启动', piRunning ? 'green' : 'orange') +
renderTag(piRuntime, 'blue') +
'' +
'
' +
'
' +
renderKv('默认模型', piDefaultModel) +
renderKv('UI 模式', piStatus && piStatus.uiMode ? piStatus.uiMode : 'independent_mnote_native_drawer') +
renderKv('Session', piStatus && piStatus.sessionId ? piStatus.sessionId : 'none') +
renderKv('Provider Session', piStatus && piStatus.providerSessionId ? piStatus.providerSessionId : 'none') +
renderKv('Runtime PID', piStatus && piStatus.pid ? String(piStatus.pid) : 'none') +
renderKv('禁用 Pi 原始工具', (piStatus && piStatus.disabledPiBuiltinTools || ['bash','read','write','edit']).join(', ')) +
renderKv('Receipt storage', piStatus && piStatus.receiptStorage ? piStatus.receiptStorage : 'control-plane / provider-neutral adapter') +
renderKv('Session dir policy', piStatus && piStatus.managedPiSessionDirPolicy ? piStatus.managedPiSessionDirPolicy : '
/.mnote/ai/pi-sessions//') +
'' +
'
' +
'
' +
'
';
}
if (channelsPanel) {
var providerCards = providers.length ? providers.map(function(p) {
var model = p.defaultModel || p.default_model || p.model || defaultModel || 'omniroute/freefirst';
return '' +
'
' + escapeHtml(p.name || p.id || p.provider || 'Provider') + '
' +
renderTag(p.enabled !== false ? '启用' : '停用', p.enabled !== false ? 'green' : 'orange') +
renderTag(p.id || p.provider || 'provider', 'blue') +
'' +
'
' +
renderKv('默认模型', model) +
renderKv('Base URL', p.baseUrl || p.base_url || p.endpoint || '-') +
renderKv('Secret 引用', p.secretRef || p.secret_ref || 'env://OMNIROUTE_API_KEY') +
renderKv('密钥策略', '只保存 env:// 或 secret:// 引用,raw key 不进前端') +
'
' +
'
';
}).join('') : '暂无 Provider 配置
';
channelsPanel.innerHTML =
'' +
'
' +
'
Omniroute
' +
renderTag('默认渠道', 'green') + renderTag('Pi: ' + piDefaultModel, 'blue') +
'' +
'
' +
renderKv('Pi 默认模型', piDefaultModel) +
renderKv('Pi Base URL', piStatus && piStatus.omnirouteBaseUrl ? piStatus.omnirouteBaseUrl : 'http://127.0.0.1:20128/v1') +
renderKv('API Key', 'env://OMNIROUTE_API_KEY / 本机配置读取') +
renderKv('Secret 策略', 'API key 与 secret 不进前端、不硬编码') +
renderKv('作用域', 'MNote provider/model policy + Pi session config') +
'
' +
'
' +
providerCards +
'
';
}
if (knowledgePanel) {
var lightrag = effectivePayload && (effectivePayload.lightragProvider || effectivePayload.lightrag_provider) || {};
knowledgePanel.innerHTML =
'' +
'
' +
'
LightRAG
' +
renderTag('唯一默认 provider', 'green') + renderTag('MNote facade', 'blue') +
'' +
'
' +
'
' +
renderKv('Provider', lightrag.provider || 'lightrag') +
renderKv('查询入口', 'mnote.knowledge_rag.query') +
renderKv('引用回跳', 'citation / open-reference') +
renderKv('Pi 接入方式', '只能通过 MNote knowledge facade 查询') +
'
' +
'
' +
'
' +
'
' +
'
知识源
' +
renderTag('复用 allowed roots', 'green') + renderTag('不引入第二套 RAG', 'orange') +
'' +
'
' +
'
资料 source/index/status 后续接 /api/knowledge-rag/*;目录授权继续来自 MNote allowed roots,不在 AI 管理页维护第二套目录。
' +
'
' +
'
' +
'
';
}
if (healthGrid) {
healthGrid.innerHTML = [
['OpenHub 默认入口', '保留', 'Page AI 主线未被 Pi Lab 替换'],
['Pi Lab', piRunning ? '运行中' : '可启动', 'runtime=' + piRuntime + ' · model=' + piDefaultModel],
['LightRAG', '默认', '唯一默认 knowledge provider,Pi 通过 MNote facade 查询'],
['Control-plane', effectivePayload && (effectivePayload.sourceOfTruth || effectivePayload.source_of_truth) || 'directory_grants', '授权、策略、receipt 真相层']
].map(function(item) {
return '' + escapeHtml(item[0]) + '
' + renderBadge(item[1], 'mnote-ai-admin-badge--active') + '
' + escapeHtml(item[2]) + '
';
}).join('');
}
}
function openUserDrawer(panel) {
selectedUserPanel = panel || selectedUserPanel || 'models';
if (drawer) drawer.classList.add('is-open');
if (selectedUserSettings) renderUserSettings(selectedUserSettings);
}
function showSaveStatus(el, type, msg) {
if (!el) return;
el.className = 'mnote-ai-admin-save-status mnote-ai-admin-save-status--' + type;
el.textContent = msg;
el.style.display = 'block';
if (type === 'success') {
setTimeout(function () { el.style.display = 'none'; }, 4000);
}
}
function renderUserList(users) {
if (!usersList) return;
if (!users || !users.length) {
usersList.innerHTML = '暂无用户
';
return;
}
var rows = users.map(function(user, idx) {
var username = user.display_name || user.username || user.id;
var role = user.role || (user.isAdmin ? 'admin' : 'user');
var status = user.disabled ? '禁用' : '启用';
var statusTone = user.disabled ? 'red' : 'green';
return '' +
'| ' + escapeHtml(String(idx + 1)) + ' | ' +
'👤 ' + escapeHtml(username) + ' ' + escapeHtml(user.email || user.id) + ' | ' +
'' + renderTag(role === 'admin' ? '管理员' : '普通', role === 'admin' ? 'blue' : '') + ' | ' +
'' + renderTag(status, statusTone) + ' | ' +
'' + escapeHtml(user.workspace_path || user.workspacePath || '未初始化') + ' | ' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
' | ' +
'
';
}).join('');
usersList.innerHTML = '' +
'| ID | 用户名 | 角色 | 状态 | 工作空间 | 操作 |
' +
'' + rows + '
';
usersList.querySelectorAll('[data-user-action]').forEach(function(button) {
button.addEventListener('click', function() {
selectedUserPanel = button.getAttribute('data-user-action') || 'models';
loadUserSettings(button.getAttribute('data-user-id') || '').then(function() {
openUserDrawer(selectedUserPanel);
});
});
});
}
function renderUserSettings(payload) {
if (!userSettingsEditor) return;
selectedUserSettings = payload;
if (drawerTitle) drawerTitle.textContent = payload.userId || '用户配置';
if (drawerSubtitle) drawerSubtitle.textContent = '逐用户模型 / 工具 / Skills / MCP / 目录权限';
if (payload.isGlobalPolicyOwner) {
userSettingsEditor.innerHTML = '该用户承载全局 AI 策略。请使用左侧“模型配置”“工具权限”“技能 / MCP”页面修改默认策略。
';
return;
}
var catalogModels = payload.catalogModels || [];
var allowedIds = new Set((payload.allowedModels || []).map(function(model) { return model.id; }));
var modelGroups = groupBy(catalogModels, function(model) { return model.providerName || model.provider || (model.id || '').split('/')[0] || 'Provider'; });
var modelRows = Object.keys(modelGroups).map(function(providerName) {
var models = modelGroups[providerName] || [];
var enabledCount = models.filter(function(model) { return allowedIds.has(model.id); }).length;
var rows = models.map(function(model) {
var usage = model.currentUsage || model.current_usage || 0;
var limit = model.monthlyLimit || model.monthly_limit || 0;
var usageText = usage ? ('已用 ' + usage + (limit ? (' / ' + limit) : '')) : (limit ? ('月限 ' + limit) : '未设置月限');
return renderSelectablePermissionRow({
title: model.name || model.id,
subtitle: model.id || '',
metaHtml: '' + escapeHtml(usageText) + '',
selectHtml: ''
});
}).join('');
return renderPermissionGroup(providerName, enabledCount + '/' + models.length + ' 已启用', rows);
}).join('');
var toolGroups = groupBy(payload.tools || [], function(tool) {
return tool.riskLevel || tool.risk_level || tool.risk || 'safe';
});
var riskLabels = { dangerous: '危险工具', moderate: '敏感工具', safe: '安全工具', custom: '自定义工具' };
var tools = ['dangerous', 'moderate', 'safe', 'custom'].map(function(risk) {
var items = toolGroups[risk] || [];
if (!items.length) return '';
var rows = items.map(function(tool) {
var maxRank = tool.globalAction === 'allow' ? 2 : (tool.globalAction === 'ask' ? 1 : 0);
var current = tool.action || tool.userAction || tool.globalAction || 'allow';
var selectHtml = '';
return renderSelectablePermissionRow({
title: tool.name,
subtitle: tool.description || ('全局 ' + actionLabel(tool.globalAction || 'allow')),
metaHtml: renderTag(actionLabel(current), actionTone(current)),
selectHtml: selectHtml,
resetHtml: tool.hasOverride ? '' : ''
});
}).join('');
return renderPermissionGroup(riskLabels[risk] || risk, items.length + ' 项', rows);
}).join('');
if (!tools && (payload.tools || []).length) {
tools = (payload.tools || []).map(function(tool) {
var maxRank = tool.globalAction === 'allow' ? 2 : (tool.globalAction === 'ask' ? 1 : 0);
return '' + escapeHtml(tool.name) + '全局 ' + escapeHtml(actionLabel(tool.globalAction)) + '' +
'
';
}).join('');
}
var enabledSkills = (payload.skills || []).filter(function(skill) { return skill.globallyEnabled !== false; });
var skills = (payload.skills || []).map(function(skill) {
return renderSelectablePermissionRow({
title: skill.name || skill.id,
subtitle: skill.description || (skill.globallyEnabled ? '全局启用' : '全局禁用'),
metaHtml: renderTag(skill.enabled ? '启用' : '禁用', skill.enabled ? 'green' : 'red'),
selectHtml: '',
resetHtml: skill.hasOverride ? '' : ''
});
}).join('');
if (skills) skills = renderPermissionGroup('Skills', enabledSkills.length + '/' + (payload.skills || []).length + ' 全局可用', skills);
var mcps = (payload.mcpServers || []).map(function(server) {
return renderSelectablePermissionRow({
title: server.name || server.id,
subtitle: (server.transport || 'stdio') + ' · facade/sandbox · ' + (server.networkPolicy || 'deny-all'),
metaHtml: renderTag(server.enabled ? '启用' : '禁用', server.enabled ? 'green' : 'red'),
selectHtml: '',
resetHtml: server.hasOverride ? '' : ''
});
}).join('');
if (mcps) mcps = renderPermissionGroup('MCP Servers', (payload.mcpServers || []).length + ' 项', mcps);
var roots = (payload.allowedRoots || payload.accessScopes || payload.roots || []).map(function(rootItem) {
return '' + escapeHtml(rootItem.rootPath || rootItem.path || rootItem.rootUri || '-') + '
' +
'
' + escapeHtml(rootItem.source || rootItem.scope || 'directory_grants') + '
' +
renderTag(rootItem.permission === 'write' ? '读写' : '只读', rootItem.permission === 'write' ? 'green' : '') + '
';
}).join('');
var activePanel = selectedUserPanel || 'models';
var header =
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'
';
var body = '';
if (activePanel === 'tools') {
body = '工具权限
按 OpenHub risk_level 分组' + (tools || '
暂无工具
') + '
';
} else if (activePanel === 'skills') {
body = 'Skills
全局启用后用户可单独降权' + (skills || '
暂无 Skill
') + '
';
} else if (activePanel === 'mcp') {
body = 'MCP
只允许 MNote facade/sandbox MCP' + (mcps || '
暂无 MCP
') + '
';
} else if (activePanel === 'roots') {
body = '' + (roots || '
目录权限由统一 allowed roots 管理。请到目录权限页调整。
') + '
';
} else {
body = '模型权限
按 provider 分组,参考 OpenHub 用户模型权限' + (modelRows || '
暂无可用模型
') +
'
';
}
userSettingsEditor.innerHTML =
'' + escapeHtml(payload.userId) + '
逐用户配置只能在管理员全局范围内降权或选择;目录权限复用 MNote allowed roots。
' +
'
' + renderTag(payload.defaultModel || '未设默认模型', 'blue') + renderTag((payload.allowedModels || []).length + ' 个模型', 'green') + ' ' +
header + body;
userSettingsEditor.querySelectorAll('[data-user-tab]').forEach(function(btn) {
btn.addEventListener('click', function() {
selectedUserPanel = btn.getAttribute('data-user-tab') || 'models';
renderUserSettings(payload);
});
});
var saveButton = userSettingsEditor.querySelector('[data-action="save-user-settings"]');
if (saveButton) saveButton.addEventListener('click', saveUserSettings);
}
async function loadUserSettings(userId) {
if (!userId) return;
selectedUserId = userId;
var usersPayload = await requestJson('/api/ai-admin/users', { method: 'GET' });
renderUserList(usersPayload.users || []);
var payload = await requestJson('/api/ai-admin/users/' + encodeURIComponent(userId) + '/settings', { method: 'GET' });
renderUserSettings(payload);
}
async function loadUsers() {
if (!isAdmin || !usersList) return;
var payload = await requestJson('/api/ai-admin/users', { method: 'GET' });
var users = payload.users || [];
if (!selectedUserId && users.length) selectedUserId = users[0].id;
renderUserList(users);
if (selectedUserId) {
var settings = await requestJson('/api/ai-admin/users/' + encodeURIComponent(selectedUserId) + '/settings', { method: 'GET' });
renderUserSettings(settings);
}
}
async function saveUserSettings() {
if (!selectedUserId || !userSettingsEditor) return;
var modelInputs = userSettingsEditor.querySelectorAll('[data-user-model]');
var allowedModels = selectedUserSettings && selectedUserSettings.allowedModels
? selectedUserSettings.allowedModels.map(function(model) { return model.id; })
: [];
if (modelInputs.length) {
allowedModels = Array.from(userSettingsEditor.querySelectorAll('[data-user-model]:checked')).map(function(input) { return input.getAttribute('data-user-model'); });
}
var defaultSelect = userSettingsEditor.querySelector('[data-user-default-model]');
var tools = {};
(selectedUserSettings && selectedUserSettings.tools || []).forEach(function(tool) {
tools[tool.name] = tool.action;
});
userSettingsEditor.querySelectorAll('[data-user-tool]').forEach(function(select) {
tools[select.getAttribute('data-user-tool')] = select.value;
});
var skills = {};
(selectedUserSettings && selectedUserSettings.skills || []).forEach(function(skill) {
skills[skill.id] = skill.enabled;
});
userSettingsEditor.querySelectorAll('[data-user-skill]').forEach(function(input) {
skills[input.getAttribute('data-user-skill')] = input.checked;
});
var mcpServers = {};
(selectedUserSettings && selectedUserSettings.mcpServers || []).forEach(function(server) {
mcpServers[server.id] = server.enabled;
});
userSettingsEditor.querySelectorAll('[data-user-mcp]').forEach(function(input) {
mcpServers[input.getAttribute('data-user-mcp')] = input.checked;
});
try {
await requestJson('/api/ai-admin/users/' + encodeURIComponent(selectedUserId) + '/settings', {
method: 'PUT',
body: JSON.stringify({
allowedModels: allowedModels,
defaultModel: defaultSelect ? defaultSelect.value : ((selectedUserSettings && selectedUserSettings.defaultModel) || ''),
tools: tools,
skills: skills,
mcpServers: mcpServers
})
});
showSaveStatus(userSettingsStatus, 'success', '用户 AI 配置已保存');
await loadUserSettings(selectedUserId);
} catch (err) {
showSaveStatus(userSettingsStatus, 'error', '保存失败: ' + (err.message || '未知错误'));
}
}
// ── 1. 加载 effective providers/models ──
async function loadEffective() {
var payload;
try {
payload = await requestJson('/api/ai-admin/effective', { method: 'GET' });
} catch (_err) {
try {
payload = await requestJson('/api/ai-settings/effective', { method: 'GET' });
} catch (err) {
setText(effectiveJson, { error: err.message || '无法加载 effective 配置', note: 'API 端点暂未实现,此为占位' });
if (overviewProvidersEl) overviewProvidersEl.textContent = '-';
if (overviewModelsEl) overviewModelsEl.textContent = '-';
if (providerTableBody) providerTableBody.innerHTML = '| 暂无数据 |
';
return;
}
}
setText(effectiveJson, payload);
var providers = payload && (payload.providers || []);
var models = payload && (payload.models || []);
var defaultModel = payload && (payload.defaultModel || payload.default_model || '');
var modelCount = Array.isArray(models) ? models.length : 0;
if (providerTableBody && Array.isArray(providers)) {
if (providers.length === 0) {
providerTableBody.innerHTML = '| 暂无配置 |
';
} else {
providerTableBody.innerHTML = providers.map(function(p) {
var name = escapeHtml(p.name || p.provider || p.id || '未知');
var model = escapeHtml(p.model || p.defaultModel || p.default_model || defaultModel || '默认');
var status = p.enabled !== false ? renderBadge('启用', 'mnote-ai-admin-badge--active') : renderBadge('停用');
var isDefault = (p.id === defaultModel || p.name === defaultModel || p.model === defaultModel)
? renderBadge('默认', 'mnote-ai-admin-badge--default') : '';
return '| ' + name + ' | ' + model + ' | ' + status + isDefault + ' | ' + escapeHtml(p.baseUrl || p.base_url || p.endpoint || '-') + ' |
';
}).join('');
}
}
if (overviewProvidersEl) overviewProvidersEl.textContent = String(Array.isArray(providers) ? providers.length : 0);
if (overviewModelsEl) overviewModelsEl.textContent = String(modelCount);
if (modelPolicyBody) {
var modelPolicy = payload.modelPolicy || payload.model_policy || {};
var rows = [
['Build 默认', modelPolicy.defaultBuildModel || modelPolicy.default_build_model || defaultModel || 'omniroute/freefirst'],
['Plan 默认', modelPolicy.defaultPlanModel || modelPolicy.default_plan_model || defaultModel || 'omniroute/freefirst'],
['Task 默认', modelPolicy.defaultTaskModel || modelPolicy.default_task_model || defaultModel || 'omniroute/freefirst'],
['Failover', Array.isArray(modelPolicy.failoverChains || modelPolicy.failover_chains) ? '已配置' : '未配置']
];
modelPolicyBody.innerHTML = rows.map(function(row) {
return '| ' + escapeHtml(row[0]) + ' | ' + escapeHtml(row[1]) + ' |
';
}).join('');
}
if (toolPolicyBody) {
var tools = payload.toolCatalog || payload.tool_catalog || [];
toolPolicyBody.innerHTML = tools.length ? tools.map(function(t) {
var action = t.defaultPolicy || t.default_policy || 'allow';
var tone = action === 'allow' ? 'mnote-ai-admin-badge--active' : '';
return '| ' + escapeHtml(t.name || '-') + ' | ' + escapeHtml(t.description || '-') + ' | ' + renderBadge(action, tone) + ' |
';
}).join('') : '| 暂无工具目录 |
';
}
var piStatus = null;
try {
piStatus = await requestJson('/api/page-ai/pi/status', { method: 'GET' });
} catch (_err) {
// Pi Lab 状态失败不阻塞 OpenHub / 管理页主面板。
}
renderServicePanels(piStatus, payload);
}
// ── 2. 加载 access scopes ──
async function loadAccessScopes() {
var payload;
try {
payload = await requestJson('/api/ai-admin/access-scopes', { method: 'GET' });
} catch (_err) {
try {
payload = await requestJson('/api/ai-settings/access-scopes', { method: 'GET' });
} catch (err) {
if (accessScopesMessage) setText(accessScopesMessage, 'access-scopes API 暂未实现,此为占位');
if (accessScopesList) accessScopesList.innerHTML = '暂无文件夹授权数据
';
if (overviewScopesEl) overviewScopesEl.textContent = '-';
return;
}
}
setText(accessScopesJson, payload);
setText(accessScopesMessage, '');
var scopes = payload && (payload.allowedRoots || payload.allowed_roots || payload.scopes || payload.accessScopes || payload.access_scopes || payload.grants || []);
if (overviewScopesEl) {
overviewScopesEl.textContent = String(Array.isArray(scopes) ? scopes.length : 0);
}
if (accessScopesList && Array.isArray(scopes)) {
if (scopes.length === 0) {
accessScopesList.innerHTML = '暂无文件夹授权
';
} else {
accessScopesList.innerHTML = scopes.map(function(s) {
var path = escapeHtml(s.rootPath || s.rootUri || s.path || s.folder || '-');
var permission = s.permission || 'read';
var meta = escapeHtml(s.source || s.scope || '');
var permBadge = permission === 'write'
? renderBadge('读写', 'mnote-ai-admin-badge--active')
: renderBadge('只读');
return '' +
'
' +
'
' + path + '
' +
'
' + meta + '
' +
'
' +
'
' + permBadge + '
' +
'
';
}).join('');
}
}
}
async function loadSessionsAndReceipts() {
var sessionsPayload = await requestJson('/api/page-ai/pi/sessions?limit=20', { method: 'GET' });
var receiptsUrl = isAdmin ? '/api/ai-admin/receipts?limit=50' : '/api/ai-settings/receipts?limit=50';
var receiptsPayload = await requestJson(receiptsUrl, { method: 'GET' });
var sessions = sessionsPayload.sessions || [];
var receipts = receiptsPayload.receipts || [];
if (sessionsBody) {
sessionsBody.innerHTML = sessions.length ? sessions.map(function(s) {
return '| ' + escapeHtml(s.title || s.preview || s.sessionId || '-') + ' | ' +
'' + escapeHtml(s.modelId || s.modelProvider || '-') + ' | ' +
'' + renderBadge(s.status || 'unknown', s.status === 'runtime_running' ? 'mnote-ai-admin-badge--active' : '') + ' | ' +
'' + escapeHtml(s.updatedAt || '-') + ' |
';
}).join('') : '| 暂无 Pi Lab 会话 |
';
}
if (receiptsBody) {
receiptsBody.innerHTML = receipts.length ? receipts.map(function(r) {
var allowed = r.allowed === true;
return '| ' + escapeHtml(r.toolName || r.tool_name || '-') + ' | ' +
'' + renderBadge(allowed ? '允许' : '拒绝', allowed ? 'mnote-ai-admin-badge--active' : '') + ' | ' +
'' + escapeHtml(r.diffSummary || r.diff_summary || r.denyReason || r.deny_reason || '-') + ' | ' +
'' + escapeHtml(r.createdAt || r.created_at || '-') + ' |
';
}).join('') : '| 暂无工具回执 |
';
}
if (receiptsSummary) {
receiptsSummary.textContent = '会话 ' + sessions.length + ' · 回执 ' +
(receiptsPayload.receiptCount || receipts.length) + ' · 文件补丁 ' +
(receiptsPayload.patchCount || 0);
}
if (usageSummary) {
usageSummary.innerHTML = '' +
'
Pi Lab 会话
' + sessions.length + '来自 ai_runtime_runs
' +
'
工具回执
' + (receiptsPayload.receiptCount || receipts.length) + '来自 ai_tool_events
' +
'
文件补丁
' + (receiptsPayload.patchCount || 0) + '来自 ai_file_patches
' +
'
';
}
}
// ════════════════════════════════════════════════
// 3. Config 内联编辑 — 模型/工具/技能 MCP
// ════════════════════════════════════════════════
// configData 保存最后一次 GET 到的全量配置
var configData = { providers: [], toolPolicies: [], skills: [], mcpServers: [] };
function objectValuesWithId(value) {
return Object.keys(value || {}).map(function(id) {
return { id: id, ...(value[id] || {}) };
});
}
function renderProviderConfig(providers) {
if (!modelsContainer) return;
if (!providers || !providers.length) {
modelsContainer.innerHTML = '暂未配置 Provider
';
return;
}
var html = '';
providers.forEach(function(p, idx) {
var allowedModels = Array.isArray(p.allowedModels) ? p.allowedModels.join(', ') : (p.allowedModels || '');
var failover = Array.isArray(p.failoverChains) ? p.failoverChains.join(', ') : (p.failoverChains || '');
var disabledAttr = isAdmin ? '' : ' disabled';
html += '';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
';
});
modelsContainer.innerHTML = '' + html;
// 绑定删除事件
if (isAdmin) {
modelsContainer.querySelectorAll('[data-action="delete-provider"]').forEach(function(btn) {
btn.addEventListener('click', function() {
var idx = parseInt(btn.getAttribute('data-idx'), 10);
if (!isNaN(idx) && configData.providers && configData.providers.length > idx) {
configData.providers.splice(idx, 1);
renderProviderConfig(configData.providers);
}
});
});
}
var providerFilter = modelsContainer.querySelector('[data-ai-admin-filter="providers"]');
if (providerFilter) {
providerFilter.addEventListener('input', function() {
var q = providerFilter.value.trim().toLowerCase();
modelsContainer.querySelectorAll('[data-provider-idx]').forEach(function(card) {
card.style.display = !q || card.textContent.toLowerCase().includes(q) ? '' : 'none';
});
});
}
}
function renderToolsConfig(tools) {
if (!toolsContainer) return;
if (!tools || !tools.length) {
toolsContainer.innerHTML = '暂未配置工具策略
';
return;
}
var riskGroups = { dangerous: 0, moderate: 0, safe: 0, custom: 0 };
tools.forEach(function(t) {
var risk = t.riskLevel || t.risk_level || t.risk || 'custom';
if (!riskGroups.hasOwnProperty(risk)) risk = 'custom';
riskGroups[risk] += 1;
});
var stats = '' +
'
🔒 危险:' + riskGroups.dangerous + '
' +
'
? 敏感:' + riskGroups.moderate + '
' +
'
✓ 安全:' + riskGroups.safe + '
' +
'
⌁ 自定义:' + riskGroups.custom + '
' +
'
';
var search = '';
var rows = tools.map(function(t, idx) {
var disabledAttr = isAdmin ? '' : ' disabled';
return '' +
'| ' + renderTag(t.name || '-', 'blue') + ' | ' +
'' + escapeHtml(t.description || '-') + ' | ' +
' | ' +
'' + renderTag(t.defaultPolicy || 'allow', t.defaultPolicy === 'deny' ? 'red' : (t.defaultPolicy === 'ask' ? 'orange' : 'green')) + ' | ' +
'
';
}).join('');
toolsContainer.innerHTML = stats + search + '';
var toolFilter = toolsContainer.querySelector('[data-ai-admin-filter="tools"]');
if (toolFilter) {
toolFilter.addEventListener('input', function() {
var q = toolFilter.value.trim().toLowerCase();
toolsContainer.querySelectorAll('tbody tr').forEach(function(row) {
row.style.display = !q || row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
});
}
}
function renderSkillsConfig(skills) {
if (!skillsContainer) return;
if (!skills || !skills.length) {
skillsContainer.innerHTML = '暂未配置 Skills
';
return;
}
var enabledCount = skills.filter(function(s) { return s.enabled !== false; }).length;
var items = skills.map(function(s, idx) {
var disabledAttr = isAdmin ? '' : ' disabled';
return '' +
'
' +
'
' +
(isAdmin ? '
' : '') +
'
';
}).join('');
skillsContainer.innerHTML =
'✓ 已启用:' + enabledCount + '/' + skills.length + '
' +
'' +
items;
if (isAdmin) {
skillsContainer.querySelectorAll('[data-action="delete-skill"]').forEach(function(btn) {
btn.addEventListener('click', function() {
var idx = parseInt(btn.getAttribute('data-idx'), 10);
if (!isNaN(idx) && configData.skills && configData.skills.length > idx) {
configData.skills.splice(idx, 1);
renderSkillsConfig(configData.skills);
}
});
});
}
var skillFilter = skillsContainer.querySelector('[data-ai-admin-filter="skills"]');
if (skillFilter) {
skillFilter.addEventListener('input', function() {
var q = skillFilter.value.trim().toLowerCase();
skillsContainer.querySelectorAll('[data-skill-idx]').forEach(function(row) {
row.style.display = !q || row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
});
}
}
function renderMCPConfig(mcpServers) {
if (!mcpContainer) return;
if (!mcpServers || !mcpServers.length) {
mcpContainer.innerHTML = '暂未配置 MCP Server
';
return;
}
var items = mcpServers.map(function(s, idx) {
var disabledAttr = isAdmin ? '' : ' disabled';
var secretRefsStr = Array.isArray(s.secretRefs) ? s.secretRefs.join(', ') : (s.secretRefs || '');
return '' +
'
' +
'
' +
'
' +
'
' +
'
' +
'' +
'
' +
'
';
}).join('');
mcpContainer.innerHTML = '' + items;
if (isAdmin) {
mcpContainer.querySelectorAll('[data-action="delete-mcp"]').forEach(function(btn) {
btn.addEventListener('click', function() {
var idx = parseInt(btn.getAttribute('data-idx'), 10);
if (!isNaN(idx) && configData.mcpServers && configData.mcpServers.length > idx) {
configData.mcpServers.splice(idx, 1);
renderMCPConfig(configData.mcpServers);
}
});
});
}
var mcpFilter = mcpContainer.querySelector('[data-ai-admin-filter="mcp"]');
if (mcpFilter) {
mcpFilter.addEventListener('input', function() {
var q = mcpFilter.value.trim().toLowerCase();
mcpContainer.querySelectorAll('[data-mcp-idx]').forEach(function(card) {
card.style.display = !q || card.textContent.toLowerCase().includes(q) ? '' : 'none';
});
});
}
}
function collectProvidersFromDOM() {
if (!modelsContainer) return [];
var cards = modelsContainer.querySelectorAll('.mnote-ai-admin-provider-card');
return Array.from(cards).map(function(card) {
function val(field) {
var el = card.querySelector('[data-field="' + field + '"]');
if (!el) return '';
if (el.type === 'checkbox') return el.checked;
return el.value;
}
var allowedRaw = val('allowedModels');
var failoverRaw = val('failoverChains');
return {
id: val('id'),
name: val('name'),
baseUrl: val('baseUrl'),
secretRef: val('secretRef'),
allowedModels: allowedRaw ? allowedRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [],
defaultModel: val('defaultModel'),
defaultBuildModel: val('defaultBuildModel'),
defaultPlanModel: val('defaultPlanModel'),
defaultTaskModel: val('defaultTaskModel'),
failoverChains: failoverRaw ? failoverRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [],
enabled: val('enabled')
};
});
}
function collectToolsFromDOM() {
if (!toolsContainer) return [];
var selects = toolsContainer.querySelectorAll('[data-tool-idx]');
var tools = [];
selects.forEach(function(sel) {
var idx = parseInt(sel.getAttribute('data-tool-idx'), 10);
if (!isNaN(idx) && configData.toolPolicies && configData.toolPolicies[idx]) {
tools[idx] = tools[idx] || JSON.parse(JSON.stringify(configData.toolPolicies[idx]));
tools[idx].defaultPolicy = sel.value;
}
});
return tools.filter(Boolean);
}
function collectSkillsFromDOM() {
if (!skillsContainer) return [];
var toggles = skillsContainer.querySelectorAll('[data-field="skill-enabled"]');
var skills = [];
toggles.forEach(function(tog) {
var idx = parseInt(tog.getAttribute('data-idx'), 10);
if (!isNaN(idx) && configData.skills && configData.skills[idx]) {
skills[idx] = skills[idx] || JSON.parse(JSON.stringify(configData.skills[idx]));
skills[idx].enabled = tog.checked;
var nameEl = skillsContainer.querySelector('[data-field="skill-name"][data-idx="' + idx + '"]');
var descriptionEl = skillsContainer.querySelector('[data-field="skill-description"][data-idx="' + idx + '"]');
skills[idx].name = nameEl ? nameEl.value.trim() : skills[idx].name;
skills[idx].description = descriptionEl ? descriptionEl.value.trim() : '';
}
});
return skills.filter(Boolean);
}
function collectMCPFromDOM() {
if (!mcpContainer) return [];
var cards = mcpContainer.querySelectorAll('[data-mcp-idx]');
var mcps = [];
cards.forEach(function(card) {
var idx = parseInt(card.getAttribute('data-mcp-idx'), 10);
if (isNaN(idx) || !configData.mcpServers || !configData.mcpServers[idx]) return;
function val(field) {
var el = card.querySelector('[data-field="' + field + '"][data-idx="' + idx + '"]');
if (!el) return '';
if (el.type === 'checkbox') return el.checked;
return el.value;
}
mcps[idx] = JSON.parse(JSON.stringify(configData.mcpServers[idx]));
mcps[idx].name = val('mcp-name');
mcps[idx].enabled = val('mcp-enabled');
mcps[idx].transport = val('mcp-transport');
mcps[idx].command = val('mcp-command');
mcps[idx].url = val('mcp-url');
mcps[idx].networkPolicy = val('mcp-network');
var secretsRaw = val('mcp-secrets');
mcps[idx].secretRefs = secretsRaw ? secretsRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [];
mcps[idx].facadeOnly = val('mcp-facade');
mcps[idx].sandbox = true;
});
return mcps.filter(Boolean);
}
// ── 添加空条目 ──
function addEmptyProvider() {
if (!configData.providers) configData.providers = [];
configData.providers.push({
id: '', name: '', baseUrl: '', secretRef: '',
allowedModels: [], defaultModel: '', defaultBuildModel: '',
defaultPlanModel: '', defaultTaskModel: '', failoverChains: [],
enabled: true
});
renderProviderConfig(configData.providers);
}
function addEmptySkill() {
if (!configData.skills) configData.skills = [];
configData.skills.push({ id: '', name: '', description: '', enabled: true });
renderSkillsConfig(configData.skills);
}
function addEmptyMCP() {
if (!configData.mcpServers) configData.mcpServers = [];
configData.mcpServers.push({
id: '', name: '', description: '', enabled: true,
transport: 'stdio', command: '', url: '',
networkPolicy: 'deny-all', secretRefs: [], facadeOnly: true
});
renderMCPConfig(configData.mcpServers);
}
// ── 加载 config ──
async function loadConfig() {
var payload;
try {
payload = await requestJson('/api/ai-admin/settings', { method: 'GET' });
} catch (err) {
if (modelsContainer) modelsContainer.innerHTML = '模型配置 API 暂不可用
';
if (toolsContainer) toolsContainer.innerHTML = '工具配置 API 暂不可用
';
if (skillsContainer) skillsContainer.innerHTML = '技能配置 API 暂不可用
';
if (mcpContainer) mcpContainer.innerHTML = 'MCP 配置 API 暂不可用
';
return;
}
configData.providers = objectValuesWithId(payload.providers);
configData.toolPolicies = (payload.toolCatalog || []).map(function(tool) {
return { ...tool, defaultPolicy: (payload.tools || {})[tool.name] || tool.defaultPolicy };
});
configData.skills = objectValuesWithId(payload.skills);
configData.mcpServers = objectValuesWithId(payload.mcpServers);
renderProviderConfig(configData.providers);
renderToolsConfig(configData.toolPolicies);
renderSkillsConfig(configData.skills);
renderMCPConfig(configData.mcpServers);
}
// ── 保存 config ──
async function saveConfig(scope) {
var body = {};
if (scope === 'models' || !scope) {
var providers = collectProvidersFromDOM();
body.providers = {};
body.allowedModels = [];
providers.forEach(function(provider) {
if (!provider.id) return;
body.providers[provider.id] = {
name: provider.name,
enabled: provider.enabled,
secretRef: provider.secretRef,
defaultModel: provider.defaultModel,
baseUrl: provider.baseUrl,
allowedModels: provider.allowedModels,
defaultBuildModel: provider.defaultBuildModel,
defaultPlanModel: provider.defaultPlanModel,
defaultTaskModel: provider.defaultTaskModel,
failoverChains: provider.failoverChains
};
body.allowedModels = body.allowedModels.concat(provider.allowedModels);
if (!body.defaultModel && provider.defaultModel) body.defaultModel = provider.defaultModel;
if (!body.buildPlanTask) {
body.buildPlanTask = {
default: provider.defaultBuildModel || provider.defaultModel || '',
failover: provider.failoverChains || []
};
}
});
}
if (scope === 'tools' || !scope) {
body.tools = {};
collectToolsFromDOM().forEach(function(tool) {
body.tools[tool.name] = tool.defaultPolicy;
});
}
if (scope === 'skills' || !scope) {
body.skills = {};
collectSkillsFromDOM().forEach(function(skill) {
var id = skill.id || skill.name;
if (!id) return;
body.skills[id] = {
name: skill.name,
enabled: skill.enabled,
description: skill.description || ''
};
});
body.mcpServers = {};
collectMCPFromDOM().forEach(function(server) {
var id = server.id || server.name;
if (!id) return;
body.mcpServers[id] = {
name: server.name,
enabled: server.enabled,
url: server.url || '',
transport: server.transport || 'stdio',
command: server.command || '',
networkPolicy: server.networkPolicy || 'deny-all',
secretRefs: server.secretRefs || [],
facadeOnly: server.facadeOnly !== false,
sandbox: true
};
});
}
try {
await requestJson('/api/ai-admin/settings', {
method: 'PUT',
body: JSON.stringify(body),
});
await loadConfig();
if (scope === 'models') {
showSaveStatus(modelsSaveStatus, 'success', '模型配置已保存');
} else if (scope === 'tools') {
showSaveStatus(toolsSaveStatus, 'success', '工具策略已保存');
} else if (scope === 'skills') {
showSaveStatus(skillsSaveStatus, 'success', '技能/MCP 配置已保存');
}
} catch (err) {
if (scope === 'models') {
showSaveStatus(modelsSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误'));
} else if (scope === 'tools') {
showSaveStatus(toolsSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误'));
} else if (scope === 'skills') {
showSaveStatus(skillsSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误'));
}
}
}
// ── 绑定保存/添加按钮 ──
if (isAdmin) {
var saveModelsBtn = root.querySelector('[data-action="save-models"]');
if (saveModelsBtn) {
saveModelsBtn.addEventListener('click', function () { saveConfig('models'); });
}
var saveToolsBtn = root.querySelector('[data-action="save-tools"]');
if (saveToolsBtn) {
saveToolsBtn.addEventListener('click', function () { saveConfig('tools'); });
}
var saveSkillsBtn = root.querySelector('[data-action="save-skills"]');
if (saveSkillsBtn) {
saveSkillsBtn.addEventListener('click', function () { saveConfig('skills'); });
}
var addProviderBtn = root.querySelector('[data-action="add-provider"]');
if (addProviderBtn) {
addProviderBtn.addEventListener('click', addEmptyProvider);
}
var addSkillBtn = root.querySelector('[data-action="add-skill"]');
if (addSkillBtn) {
addSkillBtn.addEventListener('click', addEmptySkill);
}
var addMCPBtn = root.querySelector('[data-action="add-mcp"]');
if (addMCPBtn) {
addMCPBtn.addEventListener('click', addEmptyMCP);
}
}
// ── 启动 ──
loadEffective().catch(function (err) {
if (effectiveJson) setText(effectiveJson, { error: err.message || '加载失败' });
});
loadAccessScopes().catch(function (err) {
if (accessScopesMessage) setText(accessScopesMessage, err.message || '加载失败');
});
loadSessionsAndReceipts().catch(function (err) {
if (receiptsSummary) setText(receiptsSummary, err.message || '加载会话与回执失败');
});
loadConfig().catch(function (err) {
// 静默处理,各个容器已显示错误状态
console.warn('loadConfig 失败:', err && err.message);
});
loadUsers().catch(function (err) {
if (userSettingsEditor) userSettingsEditor.innerHTML = '' + escapeHtml(err.message || '加载用户失败') + '
';
});
}
window.MNOTEInitAiAdminPage = initAiAdminPage;
var shell = document.body && document.body.getAttribute('data-mnote-shell');
if (shell === 'admin' || shell === 'user-ai-admin' || document.querySelector('[data-testid="mnote-ai-admin-page"]')) {
initAiAdminPage(document);
}
document.addEventListener('DOMContentLoaded', function () { initAiAdminPage(document); }, { once: true });
})();
"#;
/// MNOTE AI 管理页面
///
/// 左侧分页导航,包含概览、模型配置、工具权限、技能/MCP 等面板。
/// 模型/工具/技能面板由 JS 读取 config API 后渲染内联编辑表单。
/// 用户模式下所有编辑控件禁用。
#[component]
pub fn AiManagementPage(
#[prop(optional)] workspace_name: Option,
#[prop(optional, default = true)] is_admin: bool,
) -> impl IntoView {
let workspace_name = workspace_name
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "工作区".to_string());
let role_text = if is_admin {
"管理员模式 — 可管理 AI Providers 和访问策略"
} else {
"用户模式 — 查看 AI 配置与已授权文件夹"
};
let access_policy_href = if is_admin {
"/admin/access-policy"
} else {
"/user/access-policy"
};
let access_policy_label = if is_admin {
"前往管理员授权管理"
} else {
"查看我的文件夹授权"
};
let config_json = format!(
r#"{{"isAdmin":{},"workspaceName":"{}"}}"#,
if is_admin { "true" } else { "false" },
workspace_name.replace('"', "\\\"")
);
view! {
// ════════════════════════════════════════
// 1. 概览 (Overview)
// ════════════════════════════════════════
"AI Providers"
"..."
"已配置 provider 数量"
"可用模型"
"..."
"模型配置数"
"已授权文件夹"
"..."
"AI 可访问的本地文件夹数"
"工作区"
{workspace_name.clone()}
"当前上下文"
{if is_admin {
view! {
}.into_any()
} else {
view! {
"用户模式仅显示自己的 effective AI 设置。"
}.into_any()
}}
// ════════════════════════════════════════
// 2. 模型配置(内联编辑)
// ════════════════════════════════════════
"加载 OpenHub / Pi 服务状态..."
// ════════════════════════════════════════
// 3. 工具权限(内联编辑)
// ════════════════════════════════════════
// ════════════════════════════════════════
// 4. 技能 / MCP(内联编辑)
// ════════════════════════════════════════
"MCP Servers"
"MCP 通过 MNote facade 管控,不出现在 AI 原始 tool list 中。前端不出现 raw API key。"
// ════════════════════════════════════════
// 5. Access Scopes(只读)
// ════════════════════════════════════════
"当前 AI 可以访问以下已授权的本地文件夹。如需修改授权,请使用上方的授权管理入口。"
"查看 access-scopes JSON"
{""}
// ════════════════════════════════════════
// 6. Sessions & Receipts
// ════════════════════════════════════════
"加载 control-plane 会话与回执..."
"Pi Lab 会话"
| "会话" | "模型" | "状态" | "更新时间" |
| "加载会话..." |
"工具回执"
| "工具" | "结果" | "摘要 / 拒绝原因" | "时间" |
| "加载回执..." |
"用户配置"
"逐用户模型 / 工具 / Skills / MCP / 目录权限"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ssr::render_view;
#[test]
fn ai_admin_page_renders_all_panels() {
let html = render_view(view! {
});
assert!(html.contains("mnote-ai-admin-page"));
assert!(html.contains("data-ai-admin-role=\"admin\""));
assert!(html.contains("概览"));
assert!(html.contains("模型配置"));
assert!(html.contains("Access Scopes"));
assert!(html.contains("Sessions & Receipts"));
assert!(html.contains("技能 / MCP"));
assert!(html.contains("工具权限"));
assert!(html.contains("AI 管理"));
}
#[test]
fn ai_admin_page_user_mode_hides_admin_buttons() {
let html = render_view(view! {
});
assert!(html.contains("data-ai-admin-role=\"user\""));
assert!(html.contains("用户模式 — 查看 AI 配置与已授权文件夹"));
assert!(html.contains("/user/access-policy"));
assert!(html.contains("查看我的文件夹授权"));
// 内嵌脚本包含 action 字符串;用户态由 isAdmin=false 禁用编辑。
assert!(html.contains(r#""isAdmin":false"#));
assert!(AI_ADMIN_SCRIPT.contains("var disabledAttr = isAdmin ? '' : ' disabled'"));
}
#[test]
fn ai_admin_page_admin_mode_shows_save_and_add_buttons() {
let html = render_view(view! {
});
assert!(html.contains("data-ai-admin-role=\"admin\""));
assert!(html.contains("管理员模式 — 可管理 AI Providers 和访问策略"));
assert!(html.contains("/admin/access-policy"));
assert!(html.contains("前往管理员授权管理"));
// Admin 模式下有保存和添加按钮
assert!(html.contains("data-action=\"save-models\""));
assert!(html.contains("data-action=\"save-tools\""));
assert!(html.contains("data-action=\"save-skills\""));
assert!(html.contains("data-action=\"add-provider\""));
assert!(html.contains("data-action=\"add-skill\""));
assert!(html.contains("data-action=\"add-mcp\""));
}
#[test]
fn ai_admin_page_has_editable_containers_for_models_tools_skills() {
let html = render_view(view! {
});
// 面板容器 data 属性
assert!(html.contains("data-ai-admin-models-container"));
assert!(html.contains("data-ai-admin-tools-container"));
assert!(html.contains("data-ai-admin-skills-container"));
assert!(html.contains("data-ai-admin-users-list"));
assert!(html.contains("data-ai-admin-user-settings"));
assert!(html.contains("/api/ai-admin/users/"));
assert!(html.contains("data-ai-admin-mcp-container"));
// 保存状态容器
assert!(html.contains("data-ai-admin-models-save-status"));
assert!(html.contains("data-ai-admin-tools-save-status"));
assert!(html.contains("data-ai-admin-skills-save-status"));
}
#[test]
fn ai_admin_page_has_access_policy_link_and_no_access_scopes_write_forms() {
let html = render_view(view! {
});
assert!(html.contains("data-testid=\"mnote-ai-admin-access-policy-link\""));
assert!(html.contains("/admin/access-policy"));
// 不得有 access-scopes 写请求表单或按钮
assert!(!html.contains("data-admin-form=\"create-share-grant\""));
assert!(!html.contains("data-admin-action=\"revoke-access-grant\""));
assert!(!html.contains("mnote-admin-create-share-grant-submit"));
assert!(!html.contains("name=\"rootPath\""));
assert!(!html.contains("name=\"targetUserId\""));
}
#[test]
fn ai_admin_page_script_fetches_config_effective_and_access_scopes() {
assert!(AI_ADMIN_SCRIPT.contains("/api/ai-admin/settings"));
assert!(AI_ADMIN_SCRIPT.contains("/api/ai-admin/effective"));
assert!(AI_ADMIN_SCRIPT.contains("/api/ai-admin/access-scopes"));
assert!(AI_ADMIN_SCRIPT.contains("/api/ai-settings/effective"));
assert!(AI_ADMIN_SCRIPT.contains("/api/ai-settings/access-scopes"));
// 脚本包含保存逻辑
assert!(AI_ADMIN_SCRIPT.contains("PUT"));
assert!(AI_ADMIN_SCRIPT.contains("saveConfig"));
assert!(AI_ADMIN_SCRIPT.contains("loadConfig"));
}
#[test]
fn ai_admin_page_contains_edit_form_css() {
let html = render_view(view! {
});
assert!(html.contains("mnote-ai-admin-input"));
assert!(html.contains("mnote-ai-admin-select"));
assert!(html.contains("mnote-ai-admin-btn"));
assert!(html.contains("mnote-ai-admin-btn--primary"));
assert!(html.contains("mnote-ai-admin-toggle"));
assert!(html.contains("mnote-ai-admin-save-status"));
assert!(html.contains("mnote-ai-admin-provider-card"));
}
#[test]
fn ai_admin_page_stress_default_props() {
let html = render_view(view! {
});
assert!(html.contains("mnote-ai-admin-page"));
assert!(html.contains("data-ai-admin-role=\"admin\""));
}
#[test]
fn ai_admin_page_script_provides_model_role_default_dropdowns() {
// 脚本应包含 Build/Plan/Task 模型下拉选择和 failover 输入
assert!(AI_ADMIN_SCRIPT.contains("defaultBuildModel"));
assert!(AI_ADMIN_SCRIPT.contains("defaultPlanModel"));
assert!(AI_ADMIN_SCRIPT.contains("defaultTaskModel"));
assert!(AI_ADMIN_SCRIPT.contains("failoverChains"));
assert!(AI_ADMIN_SCRIPT.contains("allowedModels"));
}
#[test]
fn ai_admin_page_script_tool_policy_has_allow_ask_deny() {
// 工具策略应包含三个选项
assert!(AI_ADMIN_SCRIPT.contains("value=\"allow\""));
assert!(AI_ADMIN_SCRIPT.contains("value=\"ask\""));
assert!(AI_ADMIN_SCRIPT.contains("value=\"deny\""));
}
#[test]
fn ai_admin_page_script_mcp_has_required_fields() {
// MCP 编辑应包含 transport/command/url/network/secret/facade
assert!(AI_ADMIN_SCRIPT.contains("mcp-transport"));
assert!(AI_ADMIN_SCRIPT.contains("mcp-command"));
assert!(AI_ADMIN_SCRIPT.contains("mcp-url"));
assert!(AI_ADMIN_SCRIPT.contains("mcp-network"));
assert!(AI_ADMIN_SCRIPT.contains("mcp-secrets"));
assert!(AI_ADMIN_SCRIPT.contains("mcp-facade"));
// 只显示禁止明文密钥的说明,不提供 apiKey 字段。
assert!(!AI_ADMIN_SCRIPT.contains("apiKey"));
}
#[test]
fn ai_admin_page_script_uses_get_only_for_access_scopes() {
assert!(!AI_ADMIN_SCRIPT.contains("access-scopes', { method: 'POST'"));
assert!(!AI_ADMIN_SCRIPT.contains("access-scopes', { method: 'PUT'"));
assert!(!AI_ADMIN_SCRIPT.contains("access-scopes', { method: 'DELETE'"));
}
}