ebcc33b367
与 web 技能一并保存角色/用户默认,避免 load_skill 后缺少 web_search 工具。 Co-authored-by: Cursor <cursoragent@cursor.com>
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
clearUserCapabilityOverrides,
|
||
clearUserSkillOverrides,
|
||
getRoleCapabilities,
|
||
getRoleSkills,
|
||
getUserCapabilities,
|
||
getUserSkills,
|
||
listCapabilityCatalog,
|
||
listSkillCatalog,
|
||
updateRoleCapabilities,
|
||
updateRoleSkills,
|
||
updateUserCapabilities,
|
||
updateUserSkills,
|
||
} from '../api/client';
|
||
import type { AdminUserRow, SkillDefinition, SkillMap } from '../types';
|
||
|
||
const WEB_CAPABILITY_KEY = 'web';
|
||
const WEB_SKILL_NAME = 'web';
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
function WebSearchToolPanel({
|
||
roleEnabled,
|
||
userEnabled,
|
||
showUser,
|
||
disabled,
|
||
onRoleChange,
|
||
onUserChange,
|
||
}: {
|
||
roleEnabled: boolean;
|
||
userEnabled: boolean;
|
||
showUser: boolean;
|
||
disabled?: boolean;
|
||
onRoleChange: (enabled: boolean) => void;
|
||
onUserChange: (enabled: boolean) => void;
|
||
}) {
|
||
return (
|
||
<div className="capability-grid" style={{ marginBottom: '1.25rem' }}>
|
||
<div className="capability-group">
|
||
<div className="capability-group-header">
|
||
<h3>联网搜索工具</h3>
|
||
<span className="capability-group-count">platform/web</span>
|
||
</div>
|
||
<ul>
|
||
<li className="capability-item">
|
||
<div className="capability-item-info">
|
||
<span className="capability-label">
|
||
网页搜索
|
||
<code className="skill-name-tag">{WEB_CAPABILITY_KEY}</code>
|
||
<span className="risk-pill risk-medium">风险 中</span>
|
||
</span>
|
||
<span className="muted capability-desc">
|
||
在 Agent session 挂载 <code>platform/web</code> 扩展,提供{' '}
|
||
<code>web_search</code> / <code>fetch_url</code>。需同时开启下方{' '}
|
||
<code>{WEB_SKILL_NAME}</code> 技能;用户策略 <code>network_egress=deny</code>{' '}
|
||
时会自动禁用。
|
||
</span>
|
||
</div>
|
||
{!showUser ? (
|
||
<label className="capability-toggle" aria-label="启用网页搜索">
|
||
<input
|
||
type="checkbox"
|
||
checked={roleEnabled}
|
||
disabled={disabled}
|
||
onChange={(e) => onRoleChange(e.target.checked)}
|
||
/>
|
||
<span className="toggle-track">
|
||
<span className="toggle-thumb" />
|
||
</span>
|
||
</label>
|
||
) : (
|
||
<label className="capability-toggle" aria-label="启用网页搜索">
|
||
<input
|
||
type="checkbox"
|
||
checked={userEnabled}
|
||
disabled={disabled}
|
||
onChange={(e) => onUserChange(e.target.checked)}
|
||
/>
|
||
<span className="toggle-track">
|
||
<span className="toggle-thumb" />
|
||
</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 [roleWebEnabled, setRoleWebEnabled] = useState(false);
|
||
const [hasWebCapabilityCatalog, setHasWebCapabilityCatalog] = useState(false);
|
||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||
const [userSkills, setUserSkills] = useState<SkillMap>({});
|
||
const [userWebEnabled, setUserWebEnabled] = useState(false);
|
||
const [userWebOverride, setUserWebOverride] = useState<boolean | null>(null);
|
||
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, capabilityCatalog, roleCapabilities] = await Promise.all([
|
||
listSkillCatalog(),
|
||
getRoleSkills('user'),
|
||
listCapabilityCatalog(),
|
||
getRoleCapabilities('user'),
|
||
]);
|
||
setCatalog(catalogItems);
|
||
setRoleSkills(roleState.skills);
|
||
setHasWebCapabilityCatalog(capabilityCatalog.some((item) => item.key === WEB_CAPABILITY_KEY));
|
||
setRoleWebEnabled(Boolean(roleCapabilities.capabilities[WEB_CAPABILITY_KEY]));
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载技能目录失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadUserSkills = useCallback(async (userId: string) => {
|
||
if (!userId) {
|
||
setUserSkills({});
|
||
setUserWebEnabled(false);
|
||
setUserWebOverride(null);
|
||
setUserOverrides({});
|
||
return;
|
||
}
|
||
try {
|
||
const [state, capabilityState] = await Promise.all([
|
||
getUserSkills(userId),
|
||
getUserCapabilities(userId),
|
||
]);
|
||
setUserSkills(state.skills);
|
||
setUserOverrides(state.overrides);
|
||
setUserWebEnabled(Boolean(capabilityState.capabilities[WEB_CAPABILITY_KEY]));
|
||
setUserWebOverride(
|
||
Object.prototype.hasOwnProperty.call(capabilityState.overrides, WEB_CAPABILITY_KEY)
|
||
? Boolean(capabilityState.overrides[WEB_CAPABILITY_KEY])
|
||
: null,
|
||
);
|
||
} 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 [skillsResult] = await Promise.all([
|
||
updateRoleSkills('user', roleSkills),
|
||
hasWebCapabilityCatalog
|
||
? updateRoleCapabilities('user', { [WEB_CAPABILITY_KEY]: roleWebEnabled })
|
||
: Promise.resolve(null),
|
||
]);
|
||
setRoleSkills(skillsResult.skills);
|
||
setMessage('普通用户默认技能与联网工具已保存,并已同步到各用户工作区');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '保存失败');
|
||
}
|
||
};
|
||
|
||
const saveUserOverrides = async () => {
|
||
if (!selectedUserId) return;
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
const [skillsResult] = await Promise.all([
|
||
updateUserSkills(selectedUserId, userSkills),
|
||
hasWebCapabilityCatalog
|
||
? updateUserCapabilities(selectedUserId, { [WEB_CAPABILITY_KEY]: userWebEnabled })
|
||
: Promise.resolve(null),
|
||
]);
|
||
setUserSkills(skillsResult.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 Promise.all([
|
||
clearUserSkillOverrides(selectedUserId),
|
||
hasWebCapabilityCatalog ? clearUserCapabilityOverrides(selectedUserId) : Promise.resolve(),
|
||
]);
|
||
await loadUserSkills(selectedUserId);
|
||
setMessage('已恢复为角色默认技能与联网工具');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '重置失败');
|
||
}
|
||
};
|
||
|
||
const regularUsers = users.filter((user) => user.role === 'user');
|
||
const webSkillEnabledForRole = Boolean(roleSkills[WEB_SKILL_NAME]);
|
||
const webSkillEnabledForUser = Boolean(userSkills[WEB_SKILL_NAME]);
|
||
const webMismatchRole = useMemo(
|
||
() => hasWebCapabilityCatalog && webSkillEnabledForRole && !roleWebEnabled,
|
||
[hasWebCapabilityCatalog, webSkillEnabledForRole, roleWebEnabled],
|
||
);
|
||
const webMismatchUser = useMemo(
|
||
() => hasWebCapabilityCatalog && webSkillEnabledForUser && !userWebEnabled,
|
||
[hasWebCapabilityCatalog, webSkillEnabledForUser, userWebEnabled],
|
||
);
|
||
|
||
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
|
||
使用。开通「静态页面发布」技能会自动启用页面写入能力。联网搜索需在下方同时开启{' '}
|
||
<strong>网页搜索工具</strong> 与 <code>web</code> 技能。
|
||
</p>
|
||
{error && <p className="banner banner-error">{error}</p>}
|
||
{message && <p className="banner banner-info">{message}</p>}
|
||
{loading ? (
|
||
<p className="muted">加载中…</p>
|
||
) : !hasWebCapabilityCatalog ? (
|
||
<p className="banner banner-error">
|
||
当前 Portal 能力目录缺少 <code>web</code>,请更新 Memind 后端并重启 admin-api。
|
||
</p>
|
||
) : catalog.length === 0 ? (
|
||
<p className="muted">暂无平台技能,请在 Memind/skills/ 下添加 SKILL.md</p>
|
||
) : (
|
||
<>
|
||
{!userOnly && (
|
||
<>
|
||
<h3 className="capability-section-title">普通用户默认</h3>
|
||
<WebSearchToolPanel
|
||
roleEnabled={roleWebEnabled}
|
||
userEnabled={userWebEnabled}
|
||
showUser={false}
|
||
onRoleChange={setRoleWebEnabled}
|
||
onUserChange={setUserWebEnabled}
|
||
/>
|
||
{webMismatchRole ? (
|
||
<p className="banner banner-error">
|
||
已开启 <code>web</code> 技能但未启用网页搜索工具,Agent 将无法使用 web_search。
|
||
</p>
|
||
) : null}
|
||
<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 && (
|
||
<>
|
||
{userWebOverride != null && (
|
||
<p className="muted">联网工具已单独覆盖为 {userWebOverride ? '开启' : '关闭'}。</p>
|
||
)}
|
||
<WebSearchToolPanel
|
||
roleEnabled={roleWebEnabled}
|
||
userEnabled={userWebEnabled}
|
||
showUser
|
||
onRoleChange={setRoleWebEnabled}
|
||
onUserChange={setUserWebEnabled}
|
||
/>
|
||
{webMismatchUser ? (
|
||
<p className="banner banner-error">
|
||
该用户已开启 <code>web</code> 技能但未启用网页搜索工具。
|
||
</p>
|
||
) : null}
|
||
{Object.keys(userOverrides).length > 0 && (
|
||
<p className="muted">
|
||
已设置 {Object.keys(userOverrides).length} 项技能单独覆盖。
|
||
</p>
|
||
)}
|
||
<h3 className="capability-section-title">用户技能</h3>
|
||
<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>
|
||
);
|
||
}
|