feat(coding): add model tools and device packages

This commit is contained in:
2026-09-02 18:35:26 +08:00
parent 841273b433
commit 664b8823a0
60 changed files with 3847 additions and 2068 deletions

121
src/lib/device-packages.ts Normal file
View File

@@ -0,0 +1,121 @@
import { hostApiFetch } from '@/lib/host-api';
import type {
DevicePackageIndexV1,
DevicePackageKind,
DevicePackageRecordV1,
DevicePackageSourceKind,
} from '../../shared/device-packages';
const PACKAGE_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('device package projection is invalid');
}
return value as Record<string, unknown>;
}
function exact(value: Record<string, unknown>, keys: readonly string[]): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
throw new Error('device package projection has unexpected fields');
}
}
function text(value: unknown, maximum: number): string {
if (typeof value !== 'string' || !value || value.length > maximum) {
throw new Error('device package text is invalid');
}
return value;
}
function oneOf<T extends string>(value: unknown, choices: readonly T[]): T {
if (typeof value !== 'string' || !choices.includes(value as T)) {
throw new Error('device package value is invalid');
}
return value as T;
}
function entry(value: unknown): DevicePackageRecordV1['skillEntries'][number] {
const item = object(value);
exact(item, ['id', 'entryPath']);
return { id: text(item.id, 128), entryPath: text(item.entryPath, 1_024) };
}
function record(value: unknown): DevicePackageRecordV1 {
const item = object(value);
exact(item, [
'schemaVersion', 'packageId', 'displayName', 'resolvedVersion', 'source', 'kind',
'skillEntries', 'extensionEntries', 'enabled', 'confirmedExecutableCode', 'installedAt',
]);
const source = object(item.source);
exact(source, ['kind', 'requested', 'resolved']);
if (item.schemaVersion !== 1 || typeof item.enabled !== 'boolean'
|| typeof item.confirmedExecutableCode !== 'boolean'
|| !Array.isArray(item.skillEntries) || item.skillEntries.length > 100
|| !Array.isArray(item.extensionEntries) || item.extensionEntries.length > 100) {
throw new Error('device package record is invalid');
}
const packageId = text(item.packageId, 128);
if (!PACKAGE_ID.test(packageId)) throw new Error('device package id is invalid');
return {
schemaVersion: 1,
packageId,
displayName: text(item.displayName, 256),
resolvedVersion: text(item.resolvedVersion, 128),
source: {
kind: oneOf<DevicePackageSourceKind>(source.kind, ['npm', 'git', 'file']),
requested: text(source.requested, 2_048),
resolved: text(source.resolved, 4_096),
},
kind: oneOf<DevicePackageKind>(item.kind, ['skill-only', 'pi-extension', 'mixed']),
skillEntries: item.skillEntries.map(entry),
extensionEntries: item.extensionEntries.map((value) => text(value, 1_024)),
enabled: item.enabled,
confirmedExecutableCode: item.confirmedExecutableCode,
installedAt: text(item.installedAt, 64),
};
}
export function parseDevicePackageIndex(value: unknown): DevicePackageIndexV1 {
const index = object(value);
exact(index, ['schemaVersion', 'generation', 'packages']);
if (index.schemaVersion !== 1 || !Number.isSafeInteger(index.generation)
|| (index.generation as number) < 0 || !Array.isArray(index.packages)
|| index.packages.length > 200) throw new Error('device package index is invalid');
return {
schemaVersion: 1,
generation: index.generation as number,
packages: index.packages.map(record),
};
}
function id(value: string): string {
const normalized = value.trim();
if (!PACKAGE_ID.test(normalized)) throw new Error('device package id is invalid');
return normalized;
}
export async function readDevicePackages(): Promise<DevicePackageIndexV1> {
return parseDevicePackageIndex(await hostApiFetch('/api/coding/device-packages'));
}
export async function setDevicePackageEnabled(
packageId: string,
enabled: boolean,
): Promise<DevicePackageIndexV1> {
return parseDevicePackageIndex(await hostApiFetch(
`/api/coding/device-packages/${encodeURIComponent(id(packageId))}`,
{ method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled }) },
));
}
export async function uninstallDevicePackage(packageId: string): Promise<DevicePackageIndexV1> {
return parseDevicePackageIndex(await hostApiFetch(
`/api/coding/device-packages/${encodeURIComponent(id(packageId))}`,
{ method: 'DELETE', headers: { 'content-type': 'application/json' }, body: '{}' },
));
}
export type { DevicePackageIndexV1, DevicePackageRecordV1 } from '../../shared/device-packages';

View File

@@ -629,6 +629,37 @@ const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolD
</div>
);
}
if (details.schema === 'makelore-model-tool.v1') {
return (
<div className="rounded-xl bg-foreground/[0.035] p-3 text-xs">
<p className="font-semibold">模型联网搜索 · {details.modelId}</p>
{details.status === 'succeeded' ? (
<p className="mt-1 text-muted-foreground">
{details.sources.length > 0
? `已返回 ${details.sources.length} 个来源`
: '提供方未返回结构化来源'}
</p>
) : (
<p className="mt-1 text-destructive">{details.error.code} · {details.error.message}</p>
)}
</div>
);
}
if (details.schema === 'makelore-device-package.v1') {
const subject = details.preview?.displayName
?? details.index?.packages.at(-1)?.displayName
?? '本机包';
return (
<div className="rounded-xl bg-foreground/[0.035] p-3 text-xs">
<p className="font-semibold">本机插件 · {subject}</p>
<p className="mt-1 text-muted-foreground">
{details.operation === 'prepare'
? `等待确认${details.preview?.includesExecutableCode ? ' · 包含可执行扩展' : ''}`
: `操作完成 · 共 ${details.index?.packages.length ?? 0} 个本机包`}
</p>
</div>
);
}
return <SubagentGraph details={details} />;
});
@@ -832,6 +863,16 @@ function toolDetailsProgress(details: KnownToolDetails | undefined): string | nu
? `${details.operation} · 成功`
: `${details.operation} · ${details.error ?? '请求失败'}`;
}
if (details.schema === 'makelore-model-tool.v1') {
return details.status === 'succeeded'
? `联网搜索 · ${details.sources.length} 个来源`
: `联网搜索 · ${details.error.code}`;
}
if (details.schema === 'makelore-device-package.v1') {
return details.operation === 'prepare'
? `本机插件 · 等待确认安装 ${details.preview?.displayName ?? ''}`.trim()
: `本机插件 · ${details.operation}`;
}
const task = details.tasks.find((candidate) => (
candidate.status === 'running' || candidate.status === 'queued'
)) ?? details.tasks.at(-1);

View File

@@ -5,7 +5,9 @@ import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { MarketplaceInstallation, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace';
import type { DevicePackageIndexV1 } from '@/lib/device-packages';
import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
import { devicePackageStore, useDevicePackageStore } from '@/stores/device-packages';
import { useAuthStore } from '@/stores/auth';
import { isCodeOwnedOptionalBundledPluginId } from '../../../shared/coding-plugins';
@@ -50,19 +52,29 @@ export type MyPluginsViewProps = {
onUninstall(pluginId: string): void | Promise<void>;
onRemove(pluginId: string): void | Promise<void>;
onReacquire(pluginId: string): void | Promise<void>;
deviceIndex?: DevicePackageIndexV1 | null;
deviceState?: 'idle' | 'loading' | 'ready' | 'error';
deviceError?: string | null;
devicePending?: Record<string, true>;
onDeviceRefresh?(): void | Promise<void>;
onDeviceSetEnabled?(packageId: string, enabled: boolean): void | Promise<void>;
onDeviceUninstall?(packageId: string): void | Promise<void>;
};
export function MyPluginsView(props: MyPluginsViewProps) {
const visibleError = actionErrorText(props.error);
const devicePackages = props.deviceIndex?.packages ?? [];
const devicePending = props.devicePending ?? {};
return (
<main data-testid="my-plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
<header className="flex flex-wrap items-start justify-between gap-4"><div><h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">我的插件</h1><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">官方插件随 MakeLore 提供;其他插件的账号获取与本机下载分开管理。任何插件都不会自动启用到项目或分配给伙伴。</p></div><Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}><RefreshCw className="mr-2 h-4 w-4" />刷新</Button></header>
<header className="flex flex-wrap items-start justify-between gap-4"><div><h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">我的插件</h1><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">官方插件与这台设备上通过对话安装的 Skill、Pi extension 分开管理。本机包启用后会自动载入新建的主伙伴会话。</p></div><Button type="button" variant="outline" className="min-h-10" onClick={() => { void props.onRefresh(); void props.onDeviceRefresh?.(); }}><RefreshCw className="mr-2 h-4 w-4" />刷新</Button></header>
<h2 className="mt-8 text-xl font-semibold">官方插件</h2>
{props.library?.stale ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">正在显示上次可信的插件库;服务恢复前,在线计量或托管能力保持不可用。</p> : null}
{props.state === 'loading' && !props.library ? <p role="status" className="mt-8 text-sm">正在读取我的插件…</p> : null}
{props.state === 'error' && !props.library ? <div role="alert" className="mt-8 rounded-2xl bg-warning/10 p-5"><h2 className="font-semibold">无法读取我的插件</h2><p className="mt-2 text-pretty text-sm">{props.error ?? '请稍后重试。'}</p></div> : null}
{visibleError && props.library ? <p role="alert" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">{visibleError}</p> : null}
{props.library && props.library.items.length === 0 ? <div className="mt-8 rounded-2xl bg-surface-subtle p-6 text-center"><PackageCheck className="mx-auto h-8 w-8 text-muted-foreground" /><h2 className="mt-3 text-balance font-semibold">账号插件库还是空的</h2><p className="mt-1 text-pretty text-sm text-muted-foreground">前往插件中心免费获取需要的插件。</p><Button asChild className="mt-4 min-h-10"><Link to="/plugin-marketplace">浏览插件中心</Link></Button></div> : null}
<section aria-label="账号插件库" className="mt-6 space-y-4">
<section aria-label="官方插件" className="mt-4 space-y-4">
{props.library?.items.map((plugin) => {
const installed = props.installations[plugin.pluginId];
const systemIncluded = plugin.acquisition === 'system_included';
@@ -96,6 +108,57 @@ export function MyPluginsView(props: MyPluginsViewProps) {
</article>;
})}
</section>
<section aria-labelledby="device-packages-heading" className="mt-10">
<div>
<h2 id="device-packages-heading" className="text-xl font-semibold">本机安装</h2>
<p className="mt-1 text-pretty text-sm text-muted-foreground">
在对话中告诉 MakeLore 要安装的 npm 包、Git 仓库 Skill 或本机绝对路径;确认预览后才会写入这台设备。
</p>
</div>
{props.deviceState === 'loading' && !props.deviceIndex
? <p role="status" className="mt-4 text-sm">正在读取本机插件…</p>
: null}
{props.deviceState === 'error'
? <p role="alert" className="mt-4 rounded-xl bg-warning/10 p-3 text-sm">{props.deviceError ?? '无法读取本机插件。'}</p>
: null}
{devicePackages.length === 0 && props.deviceState !== 'loading'
? <div className="mt-4 rounded-2xl bg-surface-subtle p-5 text-sm text-muted-foreground">这台设备还没有通过对话安装的包。</div>
: null}
<div className="mt-4 space-y-4">
{devicePackages.map((item) => {
const busy = Object.keys(devicePending).some((key) => key.endsWith(`:${item.packageId}`));
const kind = item.kind === 'skill-only' ? 'Skill'
: item.kind === 'pi-extension' ? 'Pi extension' : 'Skill + Pi extension';
return <article key={item.packageId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">{item.displayName}</h3>
<Badge variant={item.enabled ? 'secondary' : 'outline'}>{item.enabled ? '已启用' : '已停用'}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">{kind} · {item.source.kind.toUpperCase()} · {item.source.requested}</p>
</div>
<p className="text-sm tabular-nums">版本 {item.resolvedVersion}</p>
</div>
{item.confirmedExecutableCode
? <p className="mt-4 text-pretty text-sm text-warning"><AlertCircle className="mr-1 inline h-4 w-4" />包含可执行 Pi extension,运行时拥有完整桌面权限。</p>
: null}
<div className="mt-5 flex flex-wrap gap-2">
<Button type="button" variant="outline" className="min-h-10" disabled={busy}
aria-label={`${item.enabled ? '停用' : '启用'} ${item.displayName}`}
onClick={() => void props.onDeviceSetEnabled?.(item.packageId, !item.enabled)}>
{item.enabled ? '停用' : '启用'}
</Button>
<Button type="button" variant="ghost" className="min-h-10" disabled={busy}
aria-label={`移除本机包 ${item.displayName}`}
onClick={() => void props.onDeviceUninstall?.(item.packageId)}>
<Trash2 className="mr-2 h-4 w-4" />移除本机包
</Button>
</div>
</article>;
})}
</div>
</section>
</main>
);
}
@@ -108,13 +171,21 @@ export function MyPlugins() {
const state = usePluginMarketplaceStore((value) => value.libraryState);
const error = usePluginMarketplaceStore((value) => value.libraryError);
const pending = usePluginMarketplaceStore((value) => value.pending);
const deviceIndex = useDevicePackageStore((value) => value.index);
const deviceState = useDevicePackageStore((value) => value.state);
const deviceError = useDevicePackageStore((value) => value.error);
const devicePending = useDevicePackageStore((value) => value.pending);
useEffect(() => {
pluginMarketplaceStore.getState().activateAccount(accountKey);
if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
}, [accountKey]);
useEffect(() => {
void devicePackageStore.getState().load().catch(() => undefined);
}, []);
const safe = (operation: Promise<void>) => operation.catch((reason) => {
toast.error(reason instanceof Error ? reason.message : String(reason));
});
const store = pluginMarketplaceStore.getState;
return <MyPluginsView library={library} installations={installations} state={state} error={error} pending={pending} onRefresh={() => safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />;
const deviceStore = devicePackageStore.getState;
return <MyPluginsView library={library} installations={installations} state={state} error={error} pending={pending} onRefresh={() => safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} deviceIndex={deviceIndex} deviceState={deviceState} deviceError={deviceError} devicePending={devicePending} onDeviceRefresh={() => safe(deviceStore().load())} onDeviceSetEnabled={(id, enabled) => safe(deviceStore().setEnabled(id, enabled))} onDeviceUninstall={(id) => safe(deviceStore().uninstall(id))} />;
}

View File

@@ -0,0 +1,96 @@
import { createStore, type StoreApi } from 'zustand';
import { useStore } from 'zustand';
import {
readDevicePackages,
setDevicePackageEnabled,
uninstallDevicePackage,
type DevicePackageIndexV1,
} from '@/lib/device-packages';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
export interface DevicePackageDependencies {
read(): Promise<DevicePackageIndexV1>;
setEnabled(packageId: string, enabled: boolean): Promise<DevicePackageIndexV1>;
uninstall(packageId: string): Promise<DevicePackageIndexV1>;
}
export interface DevicePackageState {
index: DevicePackageIndexV1 | null;
state: LoadState;
error: string | null;
pending: Record<string, true>;
load(): Promise<void>;
setEnabled(packageId: string, enabled: boolean): Promise<void>;
uninstall(packageId: string): Promise<void>;
}
const defaults: DevicePackageDependencies = {
read: readDevicePackages,
setEnabled: setDevicePackageEnabled,
uninstall: uninstallDevicePackage,
};
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function createDevicePackageStore(
overrides: Partial<DevicePackageDependencies> = {},
): StoreApi<DevicePackageState> {
const dependencies = { ...defaults, ...overrides };
let projectionIntent = 0;
return createStore<DevicePackageState>((set) => {
const mutate = async (
key: string,
operation: () => Promise<DevicePackageIndexV1>,
): Promise<void> => {
const intent = ++projectionIntent;
set((current) => ({ pending: { ...current.pending, [key]: true }, error: null }));
try {
const index = await operation();
if (intent === projectionIntent) set({ index, state: 'ready', error: null });
} catch (error) {
if (intent === projectionIntent) set({ error: message(error) });
throw error;
} finally {
set((current) => {
const pending = { ...current.pending };
delete pending[key];
return { pending };
});
}
};
return {
index: null,
state: 'idle',
error: null,
pending: {},
async load() {
const intent = ++projectionIntent;
set({ state: 'loading', error: null });
try {
const index = await dependencies.read();
if (intent === projectionIntent) set({ index, state: 'ready', error: null });
} catch (error) {
if (intent === projectionIntent) set({ state: 'error', error: message(error) });
throw error;
}
},
setEnabled: async (packageId, enabled) => await mutate(
`enabled:${packageId}`,
async () => await dependencies.setEnabled(packageId, enabled),
),
uninstall: async (packageId) => await mutate(
`uninstall:${packageId}`,
async () => await dependencies.uninstall(packageId),
),
};
});
}
export const devicePackageStore = createDevicePackageStore();
export function useDevicePackageStore<T>(selector: (state: DevicePackageState) => T): T {
return useStore(devicePackageStore, selector);
}