feat(coding): add model tools and device packages
This commit is contained in:
121
src/lib/device-packages.ts
Normal file
121
src/lib/device-packages.ts
Normal 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';
|
||||
@@ -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);
|
||||
|
||||
@@ -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))} />;
|
||||
}
|
||||
|
||||
96
src/stores/device-packages.ts
Normal file
96
src/stores/device-packages.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user