fix(coding): remediate ML-07 plugin authority

This commit is contained in:
2026-08-27 20:40:58 +08:00
parent 9407c67df2
commit cf13aa7eba
29 changed files with 1008 additions and 214 deletions

View File

@@ -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>

View File

@@ -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),

View File

@@ -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' }],

View 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>}

View File

@@ -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,
});
});
},