feat(skills): 在技能页配置联网搜索 web 能力开关

与 web 技能一并保存角色/用户默认,避免 load_skill 后缺少 web_search 工具。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-06 16:06:39 +08:00
parent 13485558b5
commit ebcc33b367
+170 -16
View File
@@ -1,14 +1,23 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
clearUserCapabilityOverrides,
clearUserSkillOverrides, clearUserSkillOverrides,
getRoleCapabilities,
getRoleSkills, getRoleSkills,
getUserCapabilities,
getUserSkills, getUserSkills,
listCapabilityCatalog,
listSkillCatalog, listSkillCatalog,
updateRoleCapabilities,
updateRoleSkills, updateRoleSkills,
updateUserCapabilities,
updateUserSkills, updateUserSkills,
} from '../api/client'; } from '../api/client';
import type { AdminUserRow, SkillDefinition, SkillMap } from '../types'; import type { AdminUserRow, SkillDefinition, SkillMap } from '../types';
const WEB_CAPABILITY_KEY = 'web';
const WEB_SKILL_NAME = 'web';
function SkillGrid({ function SkillGrid({
catalog, catalog,
values, values,
@@ -51,6 +60,75 @@ function SkillGrid({
); );
} }
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({ export function SkillSettings({
users, users,
userId, userId,
@@ -62,8 +140,12 @@ export function SkillSettings({
}) { }) {
const [catalog, setCatalog] = useState<SkillDefinition[]>([]); const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
const [roleSkills, setRoleSkills] = useState<SkillMap>({}); const [roleSkills, setRoleSkills] = useState<SkillMap>({});
const [roleWebEnabled, setRoleWebEnabled] = useState(false);
const [hasWebCapabilityCatalog, setHasWebCapabilityCatalog] = useState(false);
const [selectedUserId, setSelectedUserId] = useState(userId ?? ''); const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
const [userSkills, setUserSkills] = useState<SkillMap>({}); const [userSkills, setUserSkills] = useState<SkillMap>({});
const [userWebEnabled, setUserWebEnabled] = useState(false);
const [userWebOverride, setUserWebOverride] = useState<boolean | null>(null);
const [userOverrides, setUserOverrides] = useState<SkillMap>({}); const [userOverrides, setUserOverrides] = useState<SkillMap>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
@@ -73,12 +155,16 @@ export function SkillSettings({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const [catalogItems, roleState] = await Promise.all([ const [catalogItems, roleState, capabilityCatalog, roleCapabilities] = await Promise.all([
listSkillCatalog(), listSkillCatalog(),
getRoleSkills('user'), getRoleSkills('user'),
listCapabilityCatalog(),
getRoleCapabilities('user'),
]); ]);
setCatalog(catalogItems); setCatalog(catalogItems);
setRoleSkills(roleState.skills); setRoleSkills(roleState.skills);
setHasWebCapabilityCatalog(capabilityCatalog.some((item) => item.key === WEB_CAPABILITY_KEY));
setRoleWebEnabled(Boolean(roleCapabilities.capabilities[WEB_CAPABILITY_KEY]));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载技能目录失败'); setError(err instanceof Error ? err.message : '加载技能目录失败');
} finally { } finally {
@@ -89,13 +175,24 @@ export function SkillSettings({
const loadUserSkills = useCallback(async (userId: string) => { const loadUserSkills = useCallback(async (userId: string) => {
if (!userId) { if (!userId) {
setUserSkills({}); setUserSkills({});
setUserWebEnabled(false);
setUserWebOverride(null);
setUserOverrides({}); setUserOverrides({});
return; return;
} }
try { try {
const state = await getUserSkills(userId); const [state, capabilityState] = await Promise.all([
getUserSkills(userId),
getUserCapabilities(userId),
]);
setUserSkills(state.skills); setUserSkills(state.skills);
setUserOverrides(state.overrides); 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) { } catch (err) {
setError(err instanceof Error ? err.message : '加载用户技能失败'); setError(err instanceof Error ? err.message : '加载用户技能失败');
} }
@@ -119,9 +216,14 @@ export function SkillSettings({
setMessage(null); setMessage(null);
setError(null); setError(null);
try { try {
const result = await updateRoleSkills('user', roleSkills); const [skillsResult] = await Promise.all([
setRoleSkills(result.skills); updateRoleSkills('user', roleSkills),
setMessage('普通用户默认技能已保存,并已同步到各用户工作区'); hasWebCapabilityCatalog
? updateRoleCapabilities('user', { [WEB_CAPABILITY_KEY]: roleWebEnabled })
: Promise.resolve(null),
]);
setRoleSkills(skillsResult.skills);
setMessage('普通用户默认技能与联网工具已保存,并已同步到各用户工作区');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '保存失败'); setError(err instanceof Error ? err.message : '保存失败');
} }
@@ -132,10 +234,15 @@ export function SkillSettings({
setMessage(null); setMessage(null);
setError(null); setError(null);
try { try {
const result = await updateUserSkills(selectedUserId, userSkills); const [skillsResult] = await Promise.all([
setUserSkills(result.skills); updateUserSkills(selectedUserId, userSkills),
hasWebCapabilityCatalog
? updateUserCapabilities(selectedUserId, { [WEB_CAPABILITY_KEY]: userWebEnabled })
: Promise.resolve(null),
]);
setUserSkills(skillsResult.skills);
await loadUserSkills(selectedUserId); await loadUserSkills(selectedUserId);
setMessage('用户技能已保存,并已安装到其 MindSpace 发布目录'); setMessage('用户技能与联网工具已保存,并已安装到其 MindSpace 发布目录');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '保存失败'); setError(err instanceof Error ? err.message : '保存失败');
} }
@@ -146,15 +253,28 @@ export function SkillSettings({
setMessage(null); setMessage(null);
setError(null); setError(null);
try { try {
await clearUserSkillOverrides(selectedUserId); await Promise.all([
clearUserSkillOverrides(selectedUserId),
hasWebCapabilityCatalog ? clearUserCapabilityOverrides(selectedUserId) : Promise.resolve(),
]);
await loadUserSkills(selectedUserId); await loadUserSkills(selectedUserId);
setMessage('已恢复为角色默认技能'); setMessage('已恢复为角色默认技能与联网工具');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '重置失败'); setError(err instanceof Error ? err.message : '重置失败');
} }
}; };
const regularUsers = users.filter((user) => user.role === 'user'); 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 ( return (
<section className="admin-card"> <section className="admin-card">
@@ -166,18 +286,36 @@ export function SkillSettings({
</div> </div>
<p className="muted capability-intro"> <p className="muted capability-intro">
<code>MindSpace//.agents/skills/</code> load_skill <code>MindSpace//.agents/skills/</code> load_skill
使 使{' '}
<strong></strong> <code>web</code>
</p> </p>
{error && <p className="banner banner-error">{error}</p>} {error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>} {message && <p className="banner banner-info">{message}</p>}
{loading ? ( {loading ? (
<p className="muted"></p> <p className="muted"></p>
) : !hasWebCapabilityCatalog ? (
<p className="banner banner-error">
Portal <code>web</code> Memind admin-api
</p>
) : catalog.length === 0 ? ( ) : catalog.length === 0 ? (
<p className="muted"> Memind/skills/ SKILL.md</p> <p className="muted"> Memind/skills/ SKILL.md</p>
) : ( ) : (
<> <>
{!userOnly && ( {!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> <h3 className="capability-section-title"></h3>
<SkillGrid <SkillGrid
catalog={catalog} catalog={catalog}
@@ -186,13 +324,13 @@ export function SkillSettings({
/> />
<div className="capability-actions"> <div className="capability-actions">
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}> <button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
</button> </button>
</div> </div>
</> </>
)} )}
{!userOnly && <h3 className="capability-section-title"></h3>} {!userOnly && <h3 className="capability-section-title"></h3>}
{!userOnly && ( {!userOnly && (
<div className="admin-form capability-user-picker"> <div className="admin-form capability-user-picker">
<select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}> <select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
@@ -207,11 +345,27 @@ export function SkillSettings({
)} )}
{selectedUserId && ( {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 && ( {Object.keys(userOverrides).length > 0 && (
<p className="muted"> <p className="muted">
{Object.keys(userOverrides).length} {Object.keys(userOverrides).length}
</p> </p>
)} )}
<h3 className="capability-section-title"></h3>
<SkillGrid <SkillGrid
catalog={catalog} catalog={catalog}
values={userSkills} values={userSkills}
@@ -221,7 +375,7 @@ export function SkillSettings({
/> />
<div className="capability-actions"> <div className="capability-actions">
<button type="button" className="send-btn" onClick={() => void saveUserOverrides()}> <button type="button" className="send-btn" onClick={() => void saveUserOverrides()}>
</button> </button>
<button type="button" className="ghost-btn" onClick={() => void resetUserOverrides()}> <button type="button" className="ghost-btn" onClick={() => void resetUserOverrides()}>