fix(coding): remediate ML-07 plugin authority
This commit is contained in:
@@ -17,6 +17,8 @@ export type AgentCreationSkillOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
available?: boolean;
|
||||
effective?: boolean;
|
||||
};
|
||||
|
||||
export type AgentCreationInput = {
|
||||
@@ -194,7 +196,7 @@ export function AgentCreationDialog({
|
||||
<div className="flex items-center justify-between gap-3"><h4 className="text-sm font-semibold">绑定技能</h4><Badge className="border border-border/80 bg-background text-foreground">已选 {input.skillIds.length}</Badge></div>
|
||||
{skills.length > 0 ? <>
|
||||
<div className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /><Input aria-label="搜索技能" value={skillQuery} onChange={(event) => setSkillQuery(event.target.value)} placeholder="搜索技能" className="bg-background pl-9" /></div>
|
||||
<div className="space-y-2">{filteredSkills.map((skill) => { const checked = input.skillIds.includes(skill.id); return <label key={skill.id} className={cn('flex cursor-pointer items-start gap-2 rounded-md border border-border/80 p-2.5', checked ? 'bg-background' : 'bg-surface-subtle')}><input type="checkbox" aria-label={`绑定技能:${skill.name}`} checked={checked} onChange={() => setInput((current) => ({ ...current, skillIds: checked ? current.skillIds.filter((id) => id !== skill.id) : [...current.skillIds, skill.id] }))} /><span><span className="text-sm font-semibold">{skill.name}</span><span className="mt-0.5 block text-xs text-muted-foreground">{skill.description}</span></span></label>; })}{filteredSkills.length === 0 ? <p className="rounded-md border border-dashed border-border/80 p-3 text-center text-xs font-medium text-muted-foreground">没有匹配的技能</p> : null}</div>
|
||||
<div className="space-y-2">{filteredSkills.map((skill) => { const checked = input.skillIds.includes(skill.id); const unavailable = skill.available === false; return <label key={skill.id} className={cn('flex items-start gap-2 rounded-md border border-border/80 p-2.5', unavailable ? 'cursor-not-allowed opacity-70' : 'cursor-pointer', checked ? 'bg-background' : 'bg-surface-subtle')}><input type="checkbox" aria-label={`绑定技能:${skill.name}`} checked={checked} disabled={unavailable} onChange={() => setInput((current) => ({ ...current, skillIds: checked ? current.skillIds.filter((id) => id !== skill.id) : [...current.skillIds, skill.id] }))} /><span><span className="text-sm font-semibold">{skill.name}</span><span className="mt-0.5 block text-xs text-muted-foreground">{skill.description}</span>{unavailable ? <span className="mt-1 block text-xs font-medium text-amber-700">插件未启用;现有分配会保留,但当前不可用。</span> : null}</span></label>; })}{filteredSkills.length === 0 ? <p className="rounded-md border border-dashed border-border/80 p-3 text-center text-xs font-medium text-muted-foreground">没有匹配的技能</p> : null}</div>
|
||||
</> : <p className="mt-3 rounded-md border border-dashed border-border/80 p-3 text-xs font-medium text-muted-foreground">当前暂无可绑定技能。</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -31,13 +31,25 @@ export type PluginBilling = {
|
||||
export type CodingPluginItem = {
|
||||
id: string;
|
||||
version: string;
|
||||
contractVersion: number;
|
||||
requiresBackend: boolean;
|
||||
displayName: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
state: CodingPluginEffectiveState;
|
||||
backend: PluginBackend;
|
||||
skills: Array<{ id: string; assignedAgentIds: string[] }>;
|
||||
capabilities: Array<{ id: string; operations: Array<{ id: string; billing: PluginBilling }> }>;
|
||||
capabilities: Array<{ id: string; operations: Array<{
|
||||
id: string;
|
||||
billing: PluginBilling;
|
||||
tool: {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
mutation: 'read' | 'write' | 'destructive';
|
||||
permissions: string[];
|
||||
} | null;
|
||||
}> }>;
|
||||
settingsSurface: string | null;
|
||||
};
|
||||
|
||||
@@ -120,13 +132,18 @@ function billing(value: unknown): PluginBilling {
|
||||
|
||||
function pluginItem(value: unknown): CodingPluginItem {
|
||||
const source = record(value, 'plugin');
|
||||
exact(source, ['id', 'version', 'displayName', 'description', 'enabled', 'state', 'backend', 'skills', 'capabilities', 'settingsSurface'], 'plugin');
|
||||
if (typeof source.enabled !== 'boolean') throw new Error('plugin.enabled is invalid');
|
||||
exact(source, ['id', 'version', 'contractVersion', 'requiresBackend', 'displayName', 'description', 'enabled', 'state', 'backend', 'skills', 'capabilities', 'settingsSurface'], 'plugin');
|
||||
if (typeof source.enabled !== 'boolean' || typeof source.requiresBackend !== 'boolean'
|
||||
|| !Number.isSafeInteger(source.contractVersion) || (source.contractVersion as number) < 1) {
|
||||
throw new Error('plugin metadata is invalid');
|
||||
}
|
||||
const state = string(source.state, 'plugin.state', 32) as CodingPluginEffectiveState;
|
||||
if (!['unavailable', 'disabled', 'identity_required', 'authentication_required', 'configuration_required', 'ready', 'degraded'].includes(state)) throw new Error('plugin.state is invalid');
|
||||
if (!Array.isArray(source.skills) || !Array.isArray(source.capabilities)) throw new Error('plugin arrays are invalid');
|
||||
return {
|
||||
id: string(source.id, 'plugin.id', 128), version: string(source.version, 'plugin.version', 64),
|
||||
contractVersion: source.contractVersion as number,
|
||||
requiresBackend: source.requiresBackend,
|
||||
displayName: string(source.displayName, 'plugin.displayName'), description: string(source.description, 'plugin.description', 512),
|
||||
enabled: source.enabled, state, backend: backend(source.backend),
|
||||
skills: source.skills.map((value) => {
|
||||
@@ -138,8 +155,24 @@ function pluginItem(value: unknown): CodingPluginItem {
|
||||
const capability = record(value, 'capability'); exact(capability, ['id', 'operations'], 'capability');
|
||||
if (!Array.isArray(capability.operations)) throw new Error('capability.operations is invalid');
|
||||
return { id: string(capability.id, 'capability.id', 128), operations: capability.operations.map((value) => {
|
||||
const operation = record(value, 'operation'); exact(operation, ['id', 'billing'], 'operation');
|
||||
return { id: string(operation.id, 'operation.id', 128), billing: billing(operation.billing) };
|
||||
const operation = record(value, 'operation'); exact(operation, ['id', 'billing', 'tool'], 'operation');
|
||||
let tool: CodingPluginItem['capabilities'][number]['operations'][number]['tool'] = null;
|
||||
if (operation.tool !== null) {
|
||||
const sourceTool = record(operation.tool, 'operation.tool');
|
||||
exact(sourceTool, ['name', 'label', 'description', 'mutation', 'permissions'], 'operation.tool');
|
||||
const mutation = string(sourceTool.mutation, 'operation.tool.mutation', 16);
|
||||
if (!['read', 'write', 'destructive'].includes(mutation) || !Array.isArray(sourceTool.permissions)) {
|
||||
throw new Error('operation.tool is invalid');
|
||||
}
|
||||
tool = {
|
||||
name: string(sourceTool.name, 'operation.tool.name', 64),
|
||||
label: string(sourceTool.label, 'operation.tool.label', 128),
|
||||
description: string(sourceTool.description, 'operation.tool.description', 512),
|
||||
mutation: mutation as 'read' | 'write' | 'destructive',
|
||||
permissions: sourceTool.permissions.map((permission) => string(permission, 'operation.tool.permission', 64)),
|
||||
};
|
||||
}
|
||||
return { id: string(operation.id, 'operation.id', 128), billing: billing(operation.billing), tool };
|
||||
}) };
|
||||
}),
|
||||
settingsSurface: source.settingsSurface === null ? null : string(source.settingsSurface, 'plugin.settingsSurface', 64),
|
||||
|
||||
@@ -26,7 +26,16 @@ const EMPTY_FILES: string[] = [];
|
||||
type DrawerMode = 'models' | 'knowledge' | 'skills' | null;
|
||||
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
|
||||
type SkillStructureEntry = { path: string; type: 'file' | 'directory' };
|
||||
type SkillInfo = { id: string; name: string; description: string; location: string; content: string; entries: SkillStructureEntry[] };
|
||||
type SkillInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
available: boolean;
|
||||
effective: boolean;
|
||||
location: string;
|
||||
content: string;
|
||||
entries: SkillStructureEntry[];
|
||||
};
|
||||
|
||||
function createCustomAgent(index: number, modelKey: string): CodingProjectAgent {
|
||||
const now = new Date().toISOString();
|
||||
@@ -154,6 +163,8 @@ export function ProjectConfiguration() {
|
||||
.then((result) => setSkills(result.skills.map((skill) => ({
|
||||
id: skill.id,
|
||||
...getSkillDisplayInfo(skill.id),
|
||||
available: skill.available,
|
||||
effective: skill.effective,
|
||||
location: skill.location ?? '',
|
||||
content: skill.content ?? '',
|
||||
entries: skill.entries ?? [{ path: 'SKILL.md', type: 'file' }],
|
||||
|
||||
@@ -11,10 +11,8 @@ import { cn } from '@/lib/utils';
|
||||
import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import type { DataServiceInstanceState } from '../../../shared/data-service';
|
||||
import { BUNDLED_CODING_PLUGIN_DEFINITIONS, type CodingPluginDefinition } from '../../../shared/coding-plugins';
|
||||
|
||||
const PLUGIN_SETTINGS_SURFACES = { 'data-service': DataServicePluginSettings } as const;
|
||||
const definitions = new Map<string, CodingPluginDefinition>(BUNDLED_CODING_PLUGIN_DEFINITIONS.map((definition) => [definition.id, definition]));
|
||||
|
||||
const STATE_TEXT: Record<CodingPluginItem['state'], string> = {
|
||||
unavailable: '暂不可用', disabled: '未启用', identity_required: '需要项目 ID',
|
||||
@@ -60,8 +58,6 @@ export function ProjectPluginsView(props: ViewProps) {
|
||||
const [selectedId, setSelectedId] = useState(props.projection.items[0]?.id ?? null);
|
||||
const [disableCandidate, setDisableCandidate] = useState<CodingPluginItem | null>(null);
|
||||
const selected = props.projection.items.find(({ id }) => id === selectedId) ?? props.projection.items[0] ?? null;
|
||||
const definition = selected ? definitions.get(selected.id) : undefined;
|
||||
const billing = selected?.capabilities.flatMap(({ operations }) => operations.map(({ billing }) => billing))[0] ?? null;
|
||||
const SettingsSurface = selected?.settingsSurface && selected.settingsSurface in PLUGIN_SETTINGS_SURFACES
|
||||
? PLUGIN_SETTINGS_SURFACES[selected.settingsSurface as keyof typeof PLUGIN_SETTINGS_SURFACES]
|
||||
: null;
|
||||
@@ -92,19 +88,18 @@ export function ProjectPluginsView(props: ViewProps) {
|
||||
<section className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4" aria-label="插件概览">
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">发布者</p><p className="mt-1 font-semibold">MakeLore</p></div>
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">包版本</p><p className="mt-1 font-semibold">{selected.version}</p></div>
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">合同版本</p><p className="mt-1 font-semibold">v{definition?.contractVersion ?? '—'}</p></div>
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">后端</p><p className="mt-1 font-semibold">{definition?.requiresBackend ? '需要' : '不需要'}</p></div>
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">合同版本</p><p className="mt-1 font-semibold">v{selected.contractVersion}</p></div>
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">后端</p><p className="mt-1 font-semibold">{selected.requiresBackend ? '需要' : '不需要'}</p></div>
|
||||
</section>
|
||||
{selected.state === 'identity_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/project-config')}>创建或绑定项目 ID</Button> : null}
|
||||
{selected.state === 'authentication_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/login', { state: { from: '/project-plugins' } })}>前往登录</Button> : null}
|
||||
<section className="mt-7" aria-labelledby="plugin-capabilities-heading"><h3 id="plugin-capabilities-heading" className="text-balance text-lg font-semibold">能力与权限</h3>
|
||||
<div className="mt-3 flex flex-wrap gap-2">{selected.capabilities.map((capability) => <Badge key={capability.id} variant="secondary">{capability.id}</Badge>)}</div>
|
||||
<div className="mt-3 space-y-3">{definition?.tools.map((tool) => <div key={tool.name} className="rounded-xl bg-surface-subtle p-4"><div className="flex flex-wrap items-center gap-2"><code className="font-mono text-sm font-semibold">{tool.name}</code><Badge variant="outline">{MUTATION_TEXT[tool.mutation]}</Badge></div><p className="mt-2 text-pretty text-sm text-muted-foreground">{tool.description}</p><p className="mt-2 flex items-center gap-2 text-xs"><ShieldCheck className="h-4 w-4 text-brand" />{tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}</p></div>)}</div>
|
||||
<div className="mt-3 space-y-4">{selected.capabilities.map((capability) => <div key={capability.id} className="rounded-xl border border-border/70 p-4"><h4 className="font-mono text-sm font-semibold">{capability.id}</h4><div className="mt-3 space-y-3">{capability.operations.map((operation) => <div key={operation.id} className="rounded-lg bg-surface-subtle p-3"><div className="flex flex-wrap items-center gap-2"><code className="font-mono text-sm font-semibold">{operation.id}</code>{operation.tool ? <><Badge variant="outline">{MUTATION_TEXT[operation.tool.mutation]}</Badge><code className="text-xs text-muted-foreground">{operation.tool.name}</code></> : <Badge variant="outline">SDK</Badge>}</div>{operation.tool ? <><p className="mt-2 text-pretty text-sm text-muted-foreground">{operation.tool.description}</p><p className="mt-2 flex items-center gap-2 text-xs"><ShieldCheck className="h-4 w-4 text-brand" />{operation.tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}</p></> : <p className="mt-2 text-pretty text-sm text-muted-foreground">由生成的 Data Service SDK 使用,不作为 Pi 工具暴露。</p>}<p className="mt-2 text-pretty text-xs font-medium">{billingText(operation.billing)}</p></div>)}</div></div>)}</div>
|
||||
</section>
|
||||
<section className="mt-7" aria-labelledby="plugin-agents-heading"><div className="flex flex-wrap items-center justify-between gap-3"><h3 id="plugin-agents-heading" className="text-balance text-lg font-semibold">伙伴分配</h3><Button type="button" variant="outline" onClick={() => navigate('/project-config')}>管理伙伴技能</Button></div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">{selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).length ? selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).map((id) => <Badge key={id} variant="secondary">{props.agentNames[id] ?? '未知伙伴'}</Badge>) : <p className="text-pretty text-sm text-muted-foreground">尚未分配给任何伙伴。</p>}</div>
|
||||
</section>
|
||||
<section className="mt-7" aria-labelledby="plugin-billing-heading"><h3 id="plugin-billing-heading" className="text-balance text-lg font-semibold">用量与计费</h3>{billing ? <><p className="mt-2 text-pretty text-sm">{billingText(billing)}</p>{billing.mode === 'platform_metered' ? <p className="mt-1 text-pretty text-xs text-muted-foreground">钱包流水由权益所有者查看,当前页面不扩大财务权限。</p> : null}</> : <p className="mt-2 text-pretty text-sm">计费策略暂不可用,相关调用已停用。</p>}</section>
|
||||
<section className="mt-7" aria-labelledby="plugin-billing-heading"><h3 id="plugin-billing-heading" className="text-balance text-lg font-semibold">用量与计费</h3><p className="mt-2 text-pretty text-sm">各操作的计费模式已在上方能力列表中逐项显示;平台计量操作的流水仍由权益所有者查看。</p></section>
|
||||
{SettingsSurface && selected.enabled ? <section className="mt-7" aria-labelledby="plugin-settings-heading"><h3 id="plugin-settings-heading" className="mb-3 text-balance text-lg font-semibold">配置</h3><SettingsSurface state={selected.state} projectName={props.projectName} data={props.dataService} pending={props.pending} onConfigure={props.onConfigure} onReset={props.onReset} onRemoveCollection={props.onRemoveCollection} onRemoveProject={props.onRemoveProject} /></section> : null}
|
||||
</article>
|
||||
) : <p className="text-sm text-muted-foreground">当前没有可用插件。</p>}
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createCodingPluginsStore(overrides: Partial<Dependencies> = {}):
|
||||
removeProject: removeDataServiceProject, ...overrides,
|
||||
};
|
||||
const flights = new Map<string, Promise<void>>();
|
||||
let loadGeneration = 0;
|
||||
const operation = (key: string, run: () => Promise<void>): Promise<void> => {
|
||||
const existing = flights.get(key); if (existing) return existing;
|
||||
let flight: Promise<void>;
|
||||
@@ -57,7 +58,11 @@ export function createCodingPluginsStore(overrides: Partial<Dependencies> = {}):
|
||||
const store = createStore<CodingPluginsState>((set, get) => ({
|
||||
projectId: null, projection: null, dataService: null, loadState: 'idle', error: null, pending: {},
|
||||
load(projectId) {
|
||||
return operation(`load:${projectId}`, async () => {
|
||||
const key = `load:${projectId}`;
|
||||
const existing = flights.get(key);
|
||||
if (existing) return existing;
|
||||
const generation = ++loadGeneration;
|
||||
return operation(key, async () => {
|
||||
set({ loadState: 'loading', error: null });
|
||||
try {
|
||||
const projection = await deps.list(projectId);
|
||||
@@ -67,9 +72,13 @@ export function createCodingPluginsStore(overrides: Partial<Dependencies> = {}):
|
||||
const result = await deps.inspectDataService();
|
||||
if (result.success && result.data) dataService = result.data;
|
||||
}
|
||||
set({ projectId, projection, dataService, loadState: 'ready' });
|
||||
if (generation === loadGeneration) {
|
||||
set({ projectId, projection, dataService, loadState: 'ready', error: null });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ loadState: 'error', error: error instanceof Error ? error.message : String(error) });
|
||||
if (generation === loadGeneration) {
|
||||
set({ loadState: 'error', error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -77,9 +86,16 @@ export function createCodingPluginsStore(overrides: Partial<Dependencies> = {}):
|
||||
setEnabled(projectId, pluginId, enabled) {
|
||||
return operation(`enabled:${projectId}:${pluginId}`, async () => {
|
||||
const projection = await deps.setEnabled(projectId, pluginId, enabled);
|
||||
let dataService = get().projectId === projectId ? get().dataService : null;
|
||||
const item = projection.items.find(({ id }) => id === pluginId);
|
||||
if (!enabled) {
|
||||
dataService = null;
|
||||
} else if (item?.settingsSurface === 'data-service' && item.backend.status === 'ready') {
|
||||
const inspected = await deps.inspectDataService();
|
||||
if (inspected.success && inspected.data) dataService = inspected.data;
|
||||
}
|
||||
set({
|
||||
projectId, projection, error: null,
|
||||
...(!enabled || get().projectId !== projectId ? { dataService: null } : {}),
|
||||
projectId, projection, dataService, error: null,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user