Initial commit: tkmind admin frontend (React + Vite).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clearUserCapabilityOverrides,
|
||||
getRoleCapabilities,
|
||||
getUserCapabilities,
|
||||
listCapabilityCatalog,
|
||||
updateRoleCapabilities,
|
||||
updateUserCapabilities,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, CapabilityDefinition, CapabilityMap } from '../types';
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
};
|
||||
|
||||
type FilterType = 'all' | 'high' | 'enabled';
|
||||
|
||||
function CapabilityGrid({
|
||||
catalog,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
catalog: CapabilityDefinition[];
|
||||
values: CapabilityMap;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string, allowed: boolean) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
|
||||
const grouped = catalog.reduce<Record<string, CapabilityDefinition[]>>((acc, item) => {
|
||||
acc[item.category] ??= [];
|
||||
acc[item.category].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const filterItem = (item: CapabilityDefinition) => {
|
||||
if (filter === 'high') return item.risk === 'high';
|
||||
if (filter === 'enabled') return Boolean(values[item.key]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const FILTER_OPTIONS: { key: FilterType; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'high', label: '高风险' },
|
||||
{ key: 'enabled', label: '已启用' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="capability-grid">
|
||||
<div className="capability-filter-bar">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
className={`capability-filter-btn${filter === opt.key ? ' active' : ''}`}
|
||||
onClick={() => setFilter(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{Object.entries(grouped).map(([category, items]) => {
|
||||
const visible = items.filter(filterItem);
|
||||
if (visible.length === 0) return null;
|
||||
const enabledCount = items.filter((i) => Boolean(values[i.key])).length;
|
||||
return (
|
||||
<div key={category} className="capability-group">
|
||||
<div className="capability-group-header">
|
||||
<h3>{category}</h3>
|
||||
<span className="capability-group-count">
|
||||
{enabledCount}/{items.length} 已启用
|
||||
</span>
|
||||
</div>
|
||||
<ul>
|
||||
{visible.map((item) => {
|
||||
const checked = Boolean(values[item.key]);
|
||||
return (
|
||||
<li key={item.key} className="capability-item">
|
||||
<div className="capability-item-info">
|
||||
<span className="capability-label">
|
||||
{item.label}
|
||||
<span className={`risk-pill risk-${item.risk}`}>
|
||||
风险 {RISK_LABEL[item.risk] ?? item.risk}
|
||||
</span>
|
||||
</span>
|
||||
<span className="muted capability-desc">{item.description}</span>
|
||||
</div>
|
||||
<label className="capability-toggle" aria-label={item.label}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.key, e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track">
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapabilitySettings({
|
||||
users,
|
||||
userId,
|
||||
userOnly = false,
|
||||
}: {
|
||||
users: AdminUserRow[];
|
||||
userId?: string;
|
||||
userOnly?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<CapabilityDefinition[]>([]);
|
||||
const [roleCapabilities, setRoleCapabilities] = useState<CapabilityMap>({});
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userCapabilities, setUserCapabilities] = useState<CapabilityMap>({});
|
||||
const [userOverrides, setUserOverrides] = useState<CapabilityMap>({});
|
||||
const [userUnrestricted, setUserUnrestricted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
listCapabilityCatalog(),
|
||||
getRoleCapabilities('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRoleCapabilities(roleState.capabilities);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载权限配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserCapabilities = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserCapabilities({});
|
||||
setUserOverrides({});
|
||||
setUserUnrestricted(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserCapabilities(userId);
|
||||
setUserCapabilities(state.capabilities);
|
||||
setUserOverrides(state.overrides);
|
||||
setUserUnrestricted(state.unrestricted);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户权限失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserCapabilities(selectedUserId);
|
||||
}, [loadUserCapabilities, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setSelectedUserId(userId);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveRoleDefaults = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRoleCapabilities('user', roleCapabilities);
|
||||
setRoleCapabilities(result.capabilities);
|
||||
setMessage('普通用户默认权限已保存(对新用户及无单独配置的用户生效)');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserCapabilities(selectedUserId, userCapabilities);
|
||||
setUserCapabilities(result.capabilities);
|
||||
await loadUserCapabilities(selectedUserId);
|
||||
setMessage('用户单独权限已保存');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserCapabilityOverrides(selectedUserId);
|
||||
await loadUserCapabilities(selectedUserId);
|
||||
setMessage('已恢复为角色默认权限');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>能力权限</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
控制各扩展/工具是否可用。用户沙箱(MindSpace/用户名)请在「技能配置」勾选 static-page-publish。
|
||||
「安全策略」可进一步收紧模式与 API。管理员不受限制。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认权限</h3>
|
||||
<CapabilityGrid
|
||||
catalog={catalog}
|
||||
values={roleCapabilities}
|
||||
onChange={(key, allowed) =>
|
||||
setRoleCapabilities((prev) => ({ ...prev, [key]: allowed }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认权限
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
>
|
||||
<option value="">选择用户(可选)</option>
|
||||
{regularUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName} (@{user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{userUnrestricted ? (
|
||||
<p className="muted">该用户为管理员,不受能力限制。</p>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖,保存后将覆盖角色默认。
|
||||
</p>
|
||||
)}
|
||||
<CapabilityGrid
|
||||
catalog={catalog}
|
||||
values={userCapabilities}
|
||||
onChange={(key, allowed) =>
|
||||
setUserCapabilities((prev) => ({ ...prev, [key]: allowed }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
onClick={() => void saveUserOverrides()}
|
||||
>
|
||||
保存用户权限
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void resetUserOverrides()}
|
||||
>
|
||||
恢复角色默认
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clearUserPolicyOverrides,
|
||||
getRolePolicies,
|
||||
getUserPolicies,
|
||||
listPolicyCatalog,
|
||||
updateRolePolicies,
|
||||
updateUserPolicies,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, PolicyDefinition, PolicyMap } from '../types';
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
};
|
||||
|
||||
function PolicyGrid({
|
||||
catalog,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
catalog: PolicyDefinition[];
|
||||
values: PolicyMap;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string, value: PolicyMap[string]) => void;
|
||||
}) {
|
||||
const grouped = catalog.reduce<Record<string, PolicyDefinition[]>>((acc, item) => {
|
||||
acc[item.category] ??= [];
|
||||
acc[item.category].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="capability-grid">
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category} className="capability-group">
|
||||
<h3>{category}</h3>
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={item.key}>
|
||||
<label>
|
||||
{item.type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(values[item.key])}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.key, e.target.checked)}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={String(values[item.key] ?? item.defaultValue)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.key, e.target.value)}
|
||||
>
|
||||
{(item.options ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<span className="capability-label">
|
||||
{item.label}
|
||||
<span className={`risk-pill risk-${item.risk}`}>
|
||||
风险 {RISK_LABEL[item.risk] ?? item.risk}
|
||||
</span>
|
||||
</span>
|
||||
<span className="muted capability-desc">{item.description}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PolicySettings({
|
||||
users,
|
||||
userId,
|
||||
userOnly = false,
|
||||
}: {
|
||||
users: AdminUserRow[];
|
||||
userId?: string;
|
||||
userOnly?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<PolicyDefinition[]>([]);
|
||||
const [rolePolicies, setRolePolicies] = useState<PolicyMap>({});
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userPolicies, setUserPolicies] = useState<PolicyMap>({});
|
||||
const [userOverrides, setUserOverrides] = useState<PolicyMap>({});
|
||||
const [userUnrestricted, setUserUnrestricted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
listPolicyCatalog(),
|
||||
getRolePolicies('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRolePolicies(roleState.policies);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载安全策略失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserPolicies = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserPolicies({});
|
||||
setUserOverrides({});
|
||||
setUserUnrestricted(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserPolicies(userId);
|
||||
setUserPolicies(state.policies);
|
||||
setUserOverrides(state.overrides);
|
||||
setUserUnrestricted(state.unrestricted);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户策略失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserPolicies(selectedUserId);
|
||||
}, [loadUserPolicies, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setSelectedUserId(userId);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveRoleDefaults = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRolePolicies('user', rolePolicies);
|
||||
setRolePolicies(result.policies);
|
||||
setMessage('普通用户默认安全策略已保存');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserPolicies(selectedUserId, userPolicies);
|
||||
setUserPolicies(result.policies);
|
||||
await loadUserPolicies(selectedUserId);
|
||||
setMessage('用户单独策略已保存');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserPolicyOverrides(selectedUserId);
|
||||
await loadUserPolicies(selectedUserId);
|
||||
setMessage('已恢复为角色默认策略');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>安全策略</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
控制 TKMind 模式、工作区只读、网络出站与 API 代理锁定。与上方「能力权限」叠加生效(策略会进一步收紧高风险能力)。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认策略</h3>
|
||||
<PolicyGrid
|
||||
catalog={catalog}
|
||||
values={rolePolicies}
|
||||
onChange={(key, value) => setRolePolicies((prev) => ({ ...prev, [key]: value }))}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认策略
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户策略覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
>
|
||||
<option value="">选择用户(可选)</option>
|
||||
{regularUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName} (@{user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{userUnrestricted ? (
|
||||
<p className="muted">该用户为管理员,不受策略限制。</p>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖。
|
||||
</p>
|
||||
)}
|
||||
<PolicyGrid
|
||||
catalog={catalog}
|
||||
values={userPolicies}
|
||||
onChange={(key, value) =>
|
||||
setUserPolicies((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
onClick={() => void saveUserOverrides()}
|
||||
>
|
||||
保存用户策略
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void resetUserOverrides()}
|
||||
>
|
||||
恢复角色默认
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createLlmProviderKey,
|
||||
deleteLlmProviderKey,
|
||||
getLlmGlobalSettings,
|
||||
listLlmProviderCatalog,
|
||||
listLlmProviderKeys,
|
||||
selectLlmProviderKey,
|
||||
setLlmGlobalModel,
|
||||
syncLlmProviderToGoosed,
|
||||
testLlmProviderDraft,
|
||||
testLlmProviderKey,
|
||||
updateLlmProviderKey,
|
||||
} from '../api/client';
|
||||
import type {
|
||||
LlmConnectionTestResult,
|
||||
LlmGlobalSettings,
|
||||
LlmProviderDefinition,
|
||||
LlmProviderKeyRow,
|
||||
} from '../types';
|
||||
|
||||
const CUSTOM_PROVIDER_ID = '__custom__';
|
||||
|
||||
const emptyForm = {
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: 'Relay Buyer Ollama',
|
||||
apiKey: 'UqyHPKSSEZq0-oPnl8sru-7hZcJ2anPUL1yAVk866Vo',
|
||||
apiUrl: 'http://127.0.0.1:18300/relay/buyer/v1/chat/completions',
|
||||
basePath: '',
|
||||
modelsText: 'qwen2.5:3b',
|
||||
defaultModel: 'qwen2.5:3b',
|
||||
relayProvider: 'ollama',
|
||||
engine: 'openai',
|
||||
};
|
||||
|
||||
function parseModelsText(text: string) {
|
||||
return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function TestResultBanner({ result }: { result: LlmConnectionTestResult | null }) {
|
||||
if (!result) return null;
|
||||
if (!result.ok) {
|
||||
return <p className="banner banner-error">联通失败:{result.message ?? '未知错误'}</p>;
|
||||
}
|
||||
return (
|
||||
<p className="banner banner-info">
|
||||
联通成功({result.latencyMs}ms,模型 {result.model}):{result.reply}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderKeySettings() {
|
||||
const [catalog, setCatalog] = useState<LlmProviderDefinition[]>([]);
|
||||
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||
const [globalSettings, setGlobalSettings] = useState<LlmGlobalSettings | null>(null);
|
||||
const [globalModelDraft, setGlobalModelDraft] = useState('');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [testingForm, setTestingForm] = useState(false);
|
||||
const [testingGlobal, setTestingGlobal] = useState(false);
|
||||
const [testingKeyId, setTestingKeyId] = useState<string | null>(null);
|
||||
const [formTestResult, setFormTestResult] = useState<LlmConnectionTestResult | null>(null);
|
||||
const [globalTestResult, setGlobalTestResult] = useState<LlmConnectionTestResult | null>(null);
|
||||
|
||||
const isCustom = form.providerId === CUSTOM_PROVIDER_ID;
|
||||
const selectedCatalog = useMemo(
|
||||
() => catalog.find((item) => item.id === form.providerId) ?? null,
|
||||
[catalog, form.providerId],
|
||||
);
|
||||
const customModels = useMemo(() => parseModelsText(form.modelsText), [form.modelsText]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, keyRows, globalState] = await Promise.all([
|
||||
listLlmProviderCatalog(),
|
||||
listLlmProviderKeys(),
|
||||
getLlmGlobalSettings(),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setKeys(keyRows);
|
||||
setGlobalSettings(globalState);
|
||||
setGlobalModelDraft(globalState.globalModel ?? '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 LLM 配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCustom) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
defaultModel: current.defaultModel || customModels[0] || '',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!selectedCatalog) return;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
defaultModel: current.defaultModel || selectedCatalog.defaultModel,
|
||||
}));
|
||||
}, [customModels, isCustom, selectedCatalog]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setFormTestResult(null);
|
||||
try {
|
||||
if (isCustom) {
|
||||
await createLlmProviderKey({
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: form.name,
|
||||
apiKey: form.apiKey,
|
||||
apiUrl: form.apiUrl,
|
||||
basePath: form.basePath || undefined,
|
||||
models: customModels,
|
||||
defaultModel: form.defaultModel || customModels[0],
|
||||
engine: form.engine,
|
||||
relayProvider: form.relayProvider || undefined,
|
||||
});
|
||||
} else {
|
||||
await createLlmProviderKey({
|
||||
providerId: form.providerId,
|
||||
name: form.name,
|
||||
apiKey: form.apiKey,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
});
|
||||
}
|
||||
setForm({ ...emptyForm, providerId: form.providerId });
|
||||
setMessage('LLM 配置已保存到数据库');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestForm = async () => {
|
||||
setTestingForm(true);
|
||||
setFormTestResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await testLlmProviderDraft({
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: form.name || 'test',
|
||||
apiKey: form.apiKey,
|
||||
apiUrl: form.apiUrl,
|
||||
basePath: form.basePath || undefined,
|
||||
models: customModels,
|
||||
defaultModel: form.defaultModel || customModels[0],
|
||||
testModel: form.defaultModel || customModels[0],
|
||||
relayProvider: form.relayProvider || undefined,
|
||||
});
|
||||
setFormTestResult(result);
|
||||
} catch (err) {
|
||||
setFormTestResult({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : '联通测试失败',
|
||||
});
|
||||
} finally {
|
||||
setTestingForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGlobalModel = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setGlobalTestResult(null);
|
||||
try {
|
||||
const result = await setLlmGlobalModel(globalModelDraft);
|
||||
setGlobalSettings(result.global);
|
||||
setMessage(`全局模型已设为 ${result.global.globalModel},并已同步到 TKMind Agent`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存全局模型失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestGlobal = async () => {
|
||||
if (!globalSettings?.keyId) {
|
||||
setError('请先启用一个 LLM 配置');
|
||||
return;
|
||||
}
|
||||
setTestingGlobal(true);
|
||||
setGlobalTestResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await testLlmProviderKey(
|
||||
globalSettings.keyId,
|
||||
globalModelDraft || globalSettings.globalModel || undefined,
|
||||
);
|
||||
setGlobalTestResult(result);
|
||||
} catch (err) {
|
||||
setGlobalTestResult({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : '联通测试失败',
|
||||
});
|
||||
} finally {
|
||||
setTestingGlobal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = async (keyId: string) => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await selectLlmProviderKey(keyId);
|
||||
setMessage('已切换当前 LLM 并同步到 TKMind Agent');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '切换失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestKey = async (row: LlmProviderKeyRow) => {
|
||||
setTestingKeyId(row.id);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await testLlmProviderKey(row.id, row.defaultModel);
|
||||
if (result.ok) {
|
||||
setMessage(`「${row.name}」联通成功(${result.latencyMs}ms):${result.reply}`);
|
||||
} else {
|
||||
setError(`「${row.name}」联通失败:${result.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '联通测试失败');
|
||||
} finally {
|
||||
setTestingKeyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (row: LlmProviderKeyRow) => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await updateLlmProviderKey(row.id, {
|
||||
status: row.status === 'active' ? 'disabled' : 'active',
|
||||
});
|
||||
setMessage('状态已更新');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row: LlmProviderKeyRow) => {
|
||||
if (!window.confirm(`确定删除配置「${row.name}」?`)) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteLlmProviderKey(row.id);
|
||||
setMessage('配置已删除');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await syncLlmProviderToGoosed();
|
||||
setMessage(result.synced ? '已重新同步到 TKMind Agent' : '当前没有启用的 LLM 配置');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '同步失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>LLM Key 管理</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
服务启动时会自动导入 Relay Buyer Ollama(若尚未存在)。全局模型会写入
|
||||
GOOSE_MODEL / TKMIND_MODEL,全站对话均使用该模型。
|
||||
</p>
|
||||
|
||||
<div className="admin-card global-model-card">
|
||||
<div className="admin-card-head">
|
||||
<h3>全局模型</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : !globalSettings?.keyId ? (
|
||||
<p className="muted">尚未启用 LLM 配置。保存并启用 Relay 后即可选择全局模型。</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="muted">
|
||||
当前 Provider:<strong>{globalSettings.providerLabel}</strong>(
|
||||
{globalSettings.keyName})
|
||||
</p>
|
||||
<div className="admin-form global-model-form">
|
||||
<select
|
||||
value={globalModelDraft}
|
||||
onChange={(e) => setGlobalModelDraft(e.target.value)}
|
||||
>
|
||||
{globalSettings.availableModels.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="send-btn" onClick={() => void handleApplyGlobalModel()}>
|
||||
应用全局模型
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={testingGlobal}
|
||||
onClick={() => void handleTestGlobal()}
|
||||
>
|
||||
{testingGlobal ? '测试中…' : '联通测试'}
|
||||
</button>
|
||||
</div>
|
||||
<TestResultBanner result={globalTestResult} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="admin-form" onSubmit={handleCreate}>
|
||||
<select
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((state) => ({
|
||||
...state,
|
||||
providerId: e.target.value,
|
||||
defaultModel:
|
||||
e.target.value === CUSTOM_PROVIDER_ID
|
||||
? parseModelsText(state.modelsText)[0] ?? ''
|
||||
: (catalog.find((item) => item.id === e.target.value)?.defaultModel ?? ''),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{catalog.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
placeholder="配置名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((state) => ({ ...state, name: e.target.value }))}
|
||||
/>
|
||||
|
||||
{isCustom ? (
|
||||
<>
|
||||
<input
|
||||
placeholder="API 地址"
|
||||
value={form.apiUrl}
|
||||
onChange={(e) => setForm((state) => ({ ...state, apiUrl: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="模型列表,每行一个"
|
||||
value={form.modelsText}
|
||||
rows={4}
|
||||
onChange={(e) =>
|
||||
setForm((state) => ({
|
||||
...state,
|
||||
modelsText: e.target.value,
|
||||
defaultModel: parseModelsText(e.target.value)[0] ?? state.defaultModel,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
placeholder="Relay Provider(请求 body 中的 provider,如 ollama)"
|
||||
value={form.relayProvider}
|
||||
onChange={(e) => setForm((state) => ({ ...state, relayProvider: e.target.value }))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
placeholder="API Key / Bearer Token"
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((state) => ({ ...state, apiKey: e.target.value }))}
|
||||
/>
|
||||
|
||||
{isCustom ? (
|
||||
<select
|
||||
value={form.defaultModel || customModels[0] || ''}
|
||||
onChange={(e) => setForm((state) => ({ ...state, defaultModel: e.target.value }))}
|
||||
>
|
||||
{customModels.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
value={form.defaultModel || selectedCatalog?.defaultModel || ''}
|
||||
onChange={(e) => setForm((state) => ({ ...state, defaultModel: e.target.value }))}
|
||||
>
|
||||
{(selectedCatalog?.models ?? []).map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button type="submit" className="send-btn">
|
||||
添加配置
|
||||
</button>
|
||||
{isCustom && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={testingForm}
|
||||
onClick={() => void handleTestForm()}
|
||||
>
|
||||
{testingForm ? '测试中…' : '联通测试'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<TestResultBanner result={formTestResult} />
|
||||
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<div className="admin-card-head">
|
||||
<h3>已保存配置</h3>
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleSync()}>
|
||||
手动同步 Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="muted">暂无配置,重启服务后会自动导入 Relay Buyer Ollama。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>Endpoint</th>
|
||||
<th>模型</th>
|
||||
<th>Key</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
{row.name}
|
||||
{row.isSelected && <span className="risk-pill risk-low">当前</span>}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{row.providerKind === 'custom' ? (
|
||||
<>
|
||||
<div>{row.apiUrl}</div>
|
||||
{row.relayProvider && (
|
||||
<div className="muted">provider: {row.relayProvider}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
row.providerLabel
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">
|
||||
<div>{row.defaultModel}</div>
|
||||
{row.models.length > 1 && (
|
||||
<div className="muted">+{row.models.length - 1} 个可选</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{row.apiKeyMasked}</td>
|
||||
<td>{row.status === 'active' ? '可用' : '禁用'}</td>
|
||||
<td className="admin-actions">
|
||||
{row.providerKind === 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={testingKeyId === row.id}
|
||||
onClick={() => void handleTestKey(row)}
|
||||
>
|
||||
{testingKeyId === row.id ? '测试中…' : '测试'}
|
||||
</button>
|
||||
)}
|
||||
{!row.isSelected && row.status === 'active' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void handleSelect(row.id)}
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={row.isSelected}
|
||||
onClick={() => void handleToggleStatus(row)}
|
||||
>
|
||||
{row.status === 'active' ? '禁用' : '恢复'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={row.isSelected}
|
||||
onClick={() => void handleDelete(row)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clearUserSkillOverrides,
|
||||
getRoleSkills,
|
||||
getUserSkills,
|
||||
listSkillCatalog,
|
||||
updateRoleSkills,
|
||||
updateUserSkills,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, SkillDefinition, SkillMap } from '../types';
|
||||
|
||||
function SkillGrid({
|
||||
catalog,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
catalog: SkillDefinition[];
|
||||
values: SkillMap;
|
||||
disabled?: boolean;
|
||||
onChange: (name: string, enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="capability-grid">
|
||||
<div className="capability-group">
|
||||
<h3>平台技能</h3>
|
||||
<ul>
|
||||
{catalog.map((item) => (
|
||||
<li key={item.name}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(values[item.name])}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.name, e.target.checked)}
|
||||
/>
|
||||
<span className="capability-label">
|
||||
{item.label}
|
||||
<code className="skill-name-tag">{item.name}</code>
|
||||
{item.requiresPublish && (
|
||||
<span className="risk-pill risk-medium">含页面发布</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="muted capability-desc">{item.description}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillSettings({
|
||||
users,
|
||||
userId,
|
||||
userOnly = false,
|
||||
}: {
|
||||
users: AdminUserRow[];
|
||||
userId?: string;
|
||||
userOnly?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
|
||||
const [roleSkills, setRoleSkills] = useState<SkillMap>({});
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userSkills, setUserSkills] = useState<SkillMap>({});
|
||||
const [userOverrides, setUserOverrides] = useState<SkillMap>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
listSkillCatalog(),
|
||||
getRoleSkills('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRoleSkills(roleState.skills);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载技能目录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserSkills = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserSkills({});
|
||||
setUserOverrides({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserSkills(userId);
|
||||
setUserSkills(state.skills);
|
||||
setUserOverrides(state.overrides);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户技能失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserSkills(selectedUserId);
|
||||
}, [loadUserSkills, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setSelectedUserId(userId);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveRoleDefaults = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRoleSkills('user', roleSkills);
|
||||
setRoleSkills(result.skills);
|
||||
setMessage('普通用户默认技能已保存,并已同步到各用户工作区');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserSkills(selectedUserId, userSkills);
|
||||
setUserSkills(result.skills);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('用户技能已保存,并已安装到其 MindSpace 发布目录');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserSkillOverrides(selectedUserId);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('已恢复为角色默认技能');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>技能配置</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
勾选后技能会安装到用户 <code>MindSpace/用户名/.agents/skills/</code>,对话中可通过 load_skill
|
||||
使用。开通「静态页面发布」技能会自动启用页面写入能力。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : catalog.length === 0 ? (
|
||||
<p className="muted">暂无平台技能,请在 ui/h5/skills/ 下添加 SKILL.md</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认技能</h3>
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
values={roleSkills}
|
||||
onChange={(name, enabled) => setRoleSkills((prev) => ({ ...prev, [name]: enabled }))}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认技能
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户技能覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
|
||||
<option value="">选择用户(可选)</option>
|
||||
{regularUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName} (@{user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖。
|
||||
</p>
|
||||
)}
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
values={userSkills}
|
||||
onChange={(name, enabled) =>
|
||||
setUserSkills((prev) => ({ ...prev, [name]: enabled }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveUserOverrides()}>
|
||||
保存用户技能
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void resetUserOverrides()}>
|
||||
恢复角色默认
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user