2739 lines
124 KiB
Rust
2739 lines
124 KiB
Rust
//! 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, '"')
|
||
.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 '<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 provider,Pi 通过 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 AI,Pi 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 & 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'"));
|
||
}
|
||
}
|