@pi-harness/pi-harness 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/web/dist/assets/index-Bwqek8LF.js +20 -0
- package/apps/web/dist/assets/{index-DPgRbQ4_.css → index-DCHSASjq.css} +1 -1
- package/apps/web/dist/index.html +2 -2
- package/apps/web/package.json +6 -6
- package/apps/web/src/style.css +1 -0
- package/package.json +6 -6
- package/packages/api-gateway/dist/index.d.ts.map +1 -1
- package/packages/api-gateway/dist/index.js +19 -0
- package/packages/api-gateway/dist/index.js.map +1 -1
- package/packages/api-gateway/dist/marketplace-registry.json +62 -0
- package/packages/api-gateway/dist/marketplace.d.ts +22 -0
- package/packages/api-gateway/dist/marketplace.d.ts.map +1 -0
- package/packages/api-gateway/dist/marketplace.js +29 -0
- package/packages/api-gateway/dist/marketplace.js.map +1 -0
- package/packages/api-gateway/package.json +3 -3
- package/packages/api-gateway/src/index.ts +19 -0
- package/packages/api-gateway/src/marketplace-registry.json +62 -0
- package/packages/api-gateway/src/marketplace.ts +48 -0
- package/packages/api-gateway/test/index.test.ts +20 -0
- package/packages/api-gateway/test/marketplace.test.ts +17 -0
- package/packages/api-gateway/tsconfig.build.json +1 -1
- package/packages/bundle-web-app/package.json +2 -2
- package/packages/cli/package.json +2 -2
- package/packages/client-web/dist/control-room.d.ts +22 -0
- package/packages/client-web/dist/control-room.d.ts.map +1 -1
- package/packages/client-web/dist/control-room.js +1 -1
- package/packages/client-web/dist/control-room.js.map +1 -1
- package/packages/client-web/dist/react-room.d.ts.map +1 -1
- package/packages/client-web/dist/react-room.js +15 -4
- package/packages/client-web/dist/react-room.js.map +1 -1
- package/packages/client-web/package.json +1 -1
- package/packages/client-web/src/control-room.ts +3 -2
- package/packages/client-web/src/react-room.tsx +19 -7
- package/packages/core/package.json +1 -1
- package/packages/host-webserver/package.json +1 -1
- package/apps/web/dist/assets/index-C4tLweAB.js +0 -20
|
@@ -4,7 +4,8 @@ export interface ClientModel { readonly provider: string; readonly id: string; r
|
|
|
4
4
|
export interface ClientPlugin { readonly id: string; readonly name: string; readonly enabled: boolean; readonly state: string; }
|
|
5
5
|
export interface ClientProvider { readonly provider: string; readonly name: string; readonly active: boolean; readonly auth?: { readonly configured?: boolean; readonly source?: string; readonly label?: string }; readonly activeModel?: ClientModel; readonly models: readonly ClientModel[]; }
|
|
6
6
|
export interface ClientCommand { readonly name: string; readonly invocationName: string; readonly description?: string; readonly source?: string; }
|
|
7
|
+
export interface ClientMarketplacePlugin { readonly id: string; readonly packageName: string; readonly version: string; readonly name: string; readonly description: string; readonly author: string; readonly repository: string; readonly license: string; readonly source: "official" | "community"; readonly status: "verified" | "experimental"; readonly capabilities: readonly string[]; readonly hooks: readonly string[]; readonly profile: { readonly name: string; readonly config: Record<string, unknown> }; }
|
|
7
8
|
export interface ClientFile { readonly path: string; readonly status: string; readonly label: string; }
|
|
8
|
-
export interface ClientApi { getStatus(): Promise<ClientStatus>; getSession(): Promise<ClientSession>; getFiles(): Promise<readonly ClientFile[]>; getFileDiff(path: string): Promise<{ path: string; diff: string }>; commitFiles(paths: readonly string[], message: string): Promise<{ committed: boolean; commit?: string; message: string }>; revertFiles(paths: readonly string[]): Promise<{ reverted: boolean; paths: readonly string[] }>; prompt(value: string): Promise<{ reply: string; messages: number }>; abort(): Promise<{ aborted: boolean }>; createSession(): Promise<ClientSession>; openSession(path: string): Promise<ClientSession>; listSessions(): Promise<readonly Record<string, unknown>[]>; listModels(): Promise<readonly ClientModel[]>; listProviders(): Promise<readonly ClientProvider[]>; testProvider(provider: string): Promise<{ provider: string; reachable: boolean; auth?: unknown }>; refreshProvider(provider: string): Promise<{ provider: string; models: readonly ClientModel[] }>; listPlugins(): Promise<readonly ClientPlugin[]>; listCommands(): Promise<readonly ClientCommand[]>; selectModel(provider: string, model: string): Promise<{ model: ClientModel }>; subscribeEvents(onEvent: (payload: Record<string, unknown>) => void): () => void; }
|
|
9
|
+
export interface ClientApi { getStatus(): Promise<ClientStatus>; getSession(): Promise<ClientSession>; getFiles(): Promise<readonly ClientFile[]>; getFileDiff(path: string): Promise<{ path: string; diff: string }>; commitFiles(paths: readonly string[], message: string): Promise<{ committed: boolean; commit?: string; message: string }>; revertFiles(paths: readonly string[]): Promise<{ reverted: boolean; paths: readonly string[] }>; prompt(value: string): Promise<{ reply: string; messages: number }>; abort(): Promise<{ aborted: boolean }>; createSession(): Promise<ClientSession>; openSession(path: string): Promise<ClientSession>; listSessions(): Promise<readonly Record<string, unknown>[]>; listModels(): Promise<readonly ClientModel[]>; listProviders(): Promise<readonly ClientProvider[]>; testProvider(provider: string): Promise<{ provider: string; reachable: boolean; auth?: unknown }>; refreshProvider(provider: string): Promise<{ provider: string; models: readonly ClientModel[] }>; listPlugins(): Promise<readonly ClientPlugin[]>; listMarketplace(query?: string, capability?: string): Promise<{ items: readonly ClientMarketplacePlugin[]; capabilities: readonly string[] }>; listCommands(): Promise<readonly ClientCommand[]>; selectModel(provider: string, model: string): Promise<{ model: ClientModel }>; subscribeEvents(onEvent: (payload: Record<string, unknown>) => void): () => void; }
|
|
9
10
|
function requestJson<T>(path: string, init?: RequestInit): Promise<T> { return fetch(path, init).then(async (response) => { const payload = await response.json() as unknown; if (!response.ok) { const error = payload !== null && typeof payload === "object" && "error" in payload && typeof payload.error === "string" ? payload.error : `Request failed with status ${response.status}`; throw new Error(error); } return payload as T; }); }
|
|
10
|
-
export function createClientApi(): ClientApi { return { getStatus: () => requestJson<ClientStatus>("/api/status"), getSession: () => requestJson<ClientSession>("/api/session"), getFiles: async () => (await requestJson<{ items: readonly ClientFile[] }>("/api/files")).items, getFileDiff: (path) => requestJson<{ path: string; diff: string }>(`/api/files/diff?path=${encodeURIComponent(path)}`), commitFiles: (paths, message) => requestJson<{ committed: boolean; commit?: string; message: string }>("/api/files/commit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paths, message }) }), revertFiles: (paths) => requestJson<{ reverted: boolean; paths: readonly string[] }>("/api/files/revert", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paths, confirm: true }) }), prompt: (value) => requestJson<{ reply: string; messages: number }>("/api/prompt", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: value }) }), abort: () => requestJson<{ aborted: boolean }>("/api/abort", { method: "POST" }), createSession: () => requestJson<ClientSession>("/api/session/new", { method: "POST" }), openSession: (path) => requestJson<ClientSession>("/api/session/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) }), listSessions: async () => (await requestJson<{ items: readonly Record<string, unknown>[] }>("/api/sessions")).items, listModels: async () => (await requestJson<{ items: readonly ClientModel[] }>("/api/models")).items, listProviders: async () => (await requestJson<{ items: readonly ClientProvider[] }>("/api/providers")).items, testProvider: (provider) => requestJson<{ provider: string; reachable: boolean; auth?: unknown }>("/api/providers/test", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider }) }), refreshProvider: (provider) => requestJson<{ provider: string; models: readonly ClientModel[] }>("/api/providers/refresh", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider }) }), listPlugins: async () => (await requestJson<{ items: readonly ClientPlugin[] }>("/api/plugins")).items, listCommands: async () => (await requestJson<{ items: readonly ClientCommand[] }>("/api/commands")).items, selectModel: (provider, model) => requestJson<{ model: ClientModel }>("/api/model", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider, model }) }), subscribeEvents: (onEvent) => { if (typeof EventSource === "undefined") return () => {}; const source = new EventSource("/api/events"); source.onmessage = (event) => { try { const raw: unknown = event.data; if (typeof raw === "string") onEvent(JSON.parse(raw) as Record<string, unknown>); } catch { /* Ignore malformed frames at the network boundary. */ } }; return () => source.close(); } }; }
|
|
11
|
+
export function createClientApi(): ClientApi { return { getStatus: () => requestJson<ClientStatus>("/api/status"), getSession: () => requestJson<ClientSession>("/api/session"), getFiles: async () => (await requestJson<{ items: readonly ClientFile[] }>("/api/files")).items, getFileDiff: (path) => requestJson<{ path: string; diff: string }>(`/api/files/diff?path=${encodeURIComponent(path)}`), commitFiles: (paths, message) => requestJson<{ committed: boolean; commit?: string; message: string }>("/api/files/commit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paths, message }) }), revertFiles: (paths) => requestJson<{ reverted: boolean; paths: readonly string[] }>("/api/files/revert", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paths, confirm: true }) }), prompt: (value) => requestJson<{ reply: string; messages: number }>("/api/prompt", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: value }) }), abort: () => requestJson<{ aborted: boolean }>("/api/abort", { method: "POST" }), createSession: () => requestJson<ClientSession>("/api/session/new", { method: "POST" }), openSession: (path) => requestJson<ClientSession>("/api/session/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) }), listSessions: async () => (await requestJson<{ items: readonly Record<string, unknown>[] }>("/api/sessions")).items, listModels: async () => (await requestJson<{ items: readonly ClientModel[] }>("/api/models")).items, listProviders: async () => (await requestJson<{ items: readonly ClientProvider[] }>("/api/providers")).items, testProvider: (provider) => requestJson<{ provider: string; reachable: boolean; auth?: unknown }>("/api/providers/test", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider }) }), refreshProvider: (provider) => requestJson<{ provider: string; models: readonly ClientModel[] }>("/api/providers/refresh", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider }) }), listPlugins: async () => (await requestJson<{ items: readonly ClientPlugin[] }>("/api/plugins")).items, listMarketplace: (query = "", capability = "") => requestJson<{ items: readonly ClientMarketplacePlugin[]; capabilities: readonly string[] }>(`/api/marketplace?q=${encodeURIComponent(query)}&capability=${encodeURIComponent(capability)}`), listCommands: async () => (await requestJson<{ items: readonly ClientCommand[] }>("/api/commands")).items, selectModel: (provider, model) => requestJson<{ model: ClientModel }>("/api/model", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider, model }) }), subscribeEvents: (onEvent) => { if (typeof EventSource === "undefined") return () => {}; const source = new EventSource("/api/events"); source.onmessage = (event) => { try { const raw: unknown = event.data; if (typeof raw === "string") onEvent(JSON.parse(raw) as Record<string, unknown>); } catch { /* Ignore malformed frames at the network boundary. */ } }; return () => source.close(); } }; }
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
|
2
|
-
import { createClientApi, type ClientApi, type ClientCommand, type ClientFile, type ClientModel, type ClientPlugin, type ClientProvider, type ClientSession, type ClientStatus } from "./control-room.js";
|
|
2
|
+
import { createClientApi, type ClientApi, type ClientCommand, type ClientFile, type ClientMarketplacePlugin, type ClientModel, type ClientPlugin, type ClientProvider, type ClientSession, type ClientStatus } from "./control-room.js";
|
|
3
3
|
|
|
4
4
|
export type { ClientApi } from "./control-room.js";
|
|
5
5
|
|
|
6
6
|
type View = "chat" | "trajectory" | "files";
|
|
7
7
|
type SettingsTab = "general" | "plugins" | "providers" | "toml";
|
|
8
|
-
type Page = "session" | "plugins";
|
|
9
|
-
interface RoomData { status?: ClientStatus; session?: ClientSession; sessions: readonly Record<string, unknown>[]; files: readonly ClientFile[]; models: readonly ClientModel[]; providers: readonly ClientProvider[]; plugins: readonly ClientPlugin[]; commands: readonly ClientCommand[]; }
|
|
8
|
+
type Page = "session" | "plugins" | "marketplace";
|
|
9
|
+
interface RoomData { status?: ClientStatus; session?: ClientSession; sessions: readonly Record<string, unknown>[]; files: readonly ClientFile[]; models: readonly ClientModel[]; providers: readonly ClientProvider[]; plugins: readonly ClientPlugin[]; marketplace: readonly ClientMarketplacePlugin[]; marketplaceCapabilities: readonly string[]; commands: readonly ClientCommand[]; }
|
|
10
10
|
|
|
11
11
|
const value = (input: unknown, fallback = "—"): string => { if (input === undefined || input === null || input === "") return fallback; if (typeof input === "string" || typeof input === "number" || typeof input === "boolean" || typeof input === "bigint") return String(input); try { return JSON.stringify(input); } catch { return fallback; } };
|
|
12
12
|
const messageText = (message: Record<string, unknown>): string => { const content = message.content; if (typeof content === "string") return content; if (Array.isArray(content)) return content.map((part) => typeof part === "string" ? part : typeof part === "object" && part !== null && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "").join(""); return ""; };
|
|
@@ -49,12 +49,24 @@ function Files({ files, api, onDiff, onRefresh }: { files: readonly ClientFile[]
|
|
|
49
49
|
|
|
50
50
|
function Plugins({ plugins, tab, onTab, onToml }: { plugins: readonly ClientPlugin[]; tab: "installed" | "extensions"; onTab: (tab: "installed" | "extensions") => void; onToml: () => void }) { const groups = useMemo(() => { const map = new Map<string, string[]>(); plugins.forEach((plugin) => map.set(capability(plugin.name), [...(map.get(capability(plugin.name)) ?? []), plugin.name])); return map; }, [plugins]); return <section className="view-panel plugins-view"><div className="plugins-page"><div className="subnav"><div className="segmented"><button className={tab === "installed" ? "active" : ""} onClick={() => onTab("installed")} type="button">已安装</button><button className={tab === "extensions" ? "active" : ""} onClick={() => onTab("extensions")} type="button">扩展点</button></div><span>运行时插件清单</span><a href="#" onClick={(event) => { event.preventDefault(); onToml(); }}>在 pi.toml 里看这份清单</a></div><div className="plugins-scroll">{tab === "installed" ? <><div className="plugins-list">{plugins.map((plugin) => <article className="plugin-card" key={plugin.id}><div className="plugin-card-head"><span className="plugin-icon">◈</span><div className="plugin-copy"><div className="plugin-title"><code>{plugin.name}</code><small>{plugin.state}</small><span className="capability">{capability(plugin.name)}</span></div><p className="plugin-description">{plugin.enabled ? "由当前 Cordis loader 加载并启用,能力与 hook 由运行时注册。" : "由当前 Cordis loader 加载但已停用。"}</p><div className="hook-list"><span>loader</span><span>{plugin.state === "active" ? "active" : `state:${plugin.state}`}</span></div></div><span className={`switch ${plugin.enabled ? "on" : ""}`}><i></i></span></div></article>)}</div><div className="plugin-add"><code>dsh plugin add</code><input disabled placeholder="github:owner/repo" /><button className="primary" disabled type="button">安装</button></div></> : <><p className="extension-note">每个扩展点由哪些包占用,按 Cordis loader 的执行顺序排列。</p><div className="extension-table"><div className="extension-row extension-head"><span>扩展点</span><span>占用者(按序)</span><span>数量</span></div>{[...groups].map(([point, owners]) => <div className="extension-row" key={point}><code>{point}</code><div className="extension-owners">{owners.map((owner) => <span key={owner}>{owner}</span>)}</div><code>{owners.length}</code></div>)}</div></>}</div></div></section>; }
|
|
51
51
|
|
|
52
|
+
function Marketplace({ plugins, capabilities }: { plugins: readonly ClientMarketplacePlugin[]; capabilities: readonly string[] }) {
|
|
53
|
+
const [query, setQuery] = useState("");
|
|
54
|
+
const [capabilityFilter, setCapabilityFilter] = useState("");
|
|
55
|
+
const [copied, setCopied] = useState<string>();
|
|
56
|
+
const copyInstall = (plugin: ClientMarketplacePlugin) => {
|
|
57
|
+
const profile = JSON.stringify({ id: plugin.id, name: plugin.profile.name, config: plugin.profile.config }, null, 2);
|
|
58
|
+
const command = `npm install --save-exact ${plugin.packageName}@${plugin.version}\n\nAdd this entry to your Cordis profile:\n${profile}`;
|
|
59
|
+
void navigator.clipboard?.writeText(command).then(() => { setCopied(plugin.id); window.setTimeout(() => setCopied((current) => current === plugin.id ? undefined : current), 1800); });
|
|
60
|
+
};
|
|
61
|
+
return <section className="view-panel marketplace-view"><div className="plugins-page"><div className="marketplace-hero"><div><small>COMMUNITY MARKETPLACE</small><h2>发现 Cordis 插件</h2><p>可审查的社区目录。每个条目都包含 npm 包、版本、许可证和 Cordis 配置入口。</p></div><a href="https://github.com/pi-harness/pi-harness/blob/main/docs/plugin-marketplace.md" target="_blank" rel="noreferrer">贡献插件 ↗</a></div><div className="marketplace-toolbar"><input aria-label="搜索插件" onChange={(event) => setQuery(event.target.value)} placeholder="搜索名称、包名、能力…" value={query} /><select aria-label="按能力筛选" onChange={(event) => setCapabilityFilter(event.target.value)} value={capabilityFilter}><option value="">全部能力</option>{capabilities.map((item) => <option key={item} value={item}>{item}</option>)}</select><span>{plugins.length} 个已审核条目</span></div><div className="plugins-scroll"><div className="marketplace-list">{plugins.filter((plugin) => { const text = `${plugin.name} ${plugin.packageName} ${plugin.description} ${plugin.author} ${plugin.capabilities.join(" ")}`.toLowerCase(); return (!query || text.includes(query.toLowerCase())) && (!capabilityFilter || plugin.capabilities.includes(capabilityFilter)); }).map((plugin) => <article className="marketplace-card" key={plugin.id}><div className="marketplace-card-head"><div className="marketplace-icon">◈</div><div className="plugin-copy"><div className="plugin-title"><strong>{plugin.name}</strong><span className="marketplace-badge">{plugin.status === "verified" ? "已验证" : "实验性"}</span><span className={`marketplace-source ${plugin.source}`}>{plugin.source === "official" ? "官方" : "社区"}</span></div><code className="marketplace-package">{plugin.packageName}@{plugin.version}</code><p>{plugin.description}</p><div className="hook-list">{plugin.capabilities.map((item) => <span key={item}>{item}</span>)}{plugin.hooks.map((item) => <span key={item}>hook:{item}</span>)}</div></div><button className="marketplace-install" onClick={() => copyInstall(plugin)} type="button">{copied === plugin.id ? "已复制" : "复制安装指引"}</button></div><footer className="marketplace-meta"><span>{plugin.author} · {plugin.license}</span><a href={plugin.repository} target="_blank" rel="noreferrer">查看源码 ↗</a></footer></article>)}{!plugins.length && <div className="empty-state">没有匹配的插件。</div>}</div><div className="marketplace-contribute"><strong>你有一个 Cordis 插件?</strong><span>在 registry JSON 添加元数据,附测试和 README 后提交 PR;审核通过后会出现在这里。</span><a href="https://github.com/pi-harness/pi-harness/blob/main/docs/plugin-marketplace.md" target="_blank" rel="noreferrer">查看贡献规范 ↗</a></div></div></div></section>;
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
function Settings({ data, api, tab, onTab, onClose }: { data: RoomData; api: ClientApi; tab: SettingsTab; onTab: (tab: SettingsTab) => void; onClose: () => void }) { const status = data.status; const [providerState, setProviderState] = useState<Record<string, string>>({}); const runProviderAction = (provider: string, action: "test" | "refresh") => { setProviderState((current) => ({ ...current, [provider]: action === "test" ? "测试中…" : "刷新中…" })); if (action === "test") void api.testProvider(provider).then((result) => setProviderState((current) => ({ ...current, [provider]: result.reachable ? "连接正常" : "未检测到认证" }))).catch((cause: unknown) => setProviderState((current) => ({ ...current, [provider]: cause instanceof Error ? cause.message : String(cause) }))); else void api.refreshProvider(provider).then((result) => setProviderState((current) => ({ ...current, [provider]: `${result.models.length} 个模型已刷新` }))).catch((cause: unknown) => setProviderState((current) => ({ ...current, [provider]: cause instanceof Error ? cause.message : String(cause) }))); }; return <div className="settings-overlay"><div className="settings-dialog"><aside><strong>设置</strong>{(["general", "plugins", "providers", "toml"] as const).map((item) => <button className={`settings-tab ${tab === item ? "active" : ""}`} key={item} onClick={() => onTab(item)} type="button">{item === "general" ? "通用" : item === "plugins" ? `插件 ${data.plugins.length}` : item === "providers" ? `提供商 ${data.providers.length}` : "pi.toml"}</button>)}</aside><section><header><strong>{tab === "general" ? "通用" : tab === "plugins" ? "插件" : tab === "providers" ? "提供商" : "pi.toml"}</strong><small>{tab === "toml" ? "配置即代码,改完重载" : "运行时状态与快捷键"}</small><button onClick={onClose} type="button">×</button></header><div className="settings-body">{tab === "general" && <>{[["工作目录", status?.cwd], ["agent 目录", status?.agentDir], ["会话", status ? `${status.sessionId} · ${status.messages} 条消息` : "—"], ["快捷键", "⌘K 命令 · ⌘, 设置 · ⌃C 中断"], ["权限策略", "当前 API 未提供修改接口"]].map(([key, item]) => <div className="general-row" key={key}><div><strong>{key}</strong><small>{value(item)}</small></div></div>)}<div className="general-row"><div><strong>任务结束提醒</strong><small>由 runtime 插件提供</small></div><span className="switch"><i></i></span></div><div className="general-row"><div><strong>自动压缩上下文</strong><small>事件通过 SSE 实时刷新</small></div><span className="switch on"><i></i></span></div></>}{tab === "plugins" && data.plugins.map((plugin) => <div className="settings-plugin-row" key={plugin.id}>◈ <code>{plugin.name} · {plugin.id} · {plugin.state}</code></div>)}{tab === "providers" && <><small>运行时注册提供商 · /api/providers</small>{data.providers.length ? data.providers.map((provider) => <article className="provider-card" key={provider.provider}><div className="provider-head"><span className="provider-dot">●</span><strong>{provider.name}</strong><span className="provider-state">{provider.active ? "当前会话" : value(provider.auth?.configured, "未配置")}</span></div><div className="provider-field"><code>provider</code><input disabled value={provider.provider} readOnly /></div><div className="provider-field"><code>model</code><input disabled value={provider.activeModel ? `${provider.activeModel.provider}/${provider.activeModel.id}` : provider.models.map((model) => model.id).join(", ")} readOnly /></div><div className="provider-field"><code>api_key</code><input disabled value="不会在浏览器显示" readOnly /></div><div className="provider-footer"><code>{provider.models.length} 个模型</code><button onClick={() => runProviderAction(provider.provider, "test")} type="button">测试连接</button><button className="link-button" onClick={() => runProviderAction(provider.provider, "refresh")} type="button">拉取模型</button></div>{providerState[provider.provider] && <small className="provider-result">{providerState[provider.provider]}</small>}</article>) : <div className="empty-state">运行时没有注册提供商。</div>}</>}{tab === "toml" && <><div className="toml-toolbar"><span>~/.config/pi/pi.toml</span><button className="active" type="button">表单</button><button disabled type="button">源码</button><button className="primary" disabled type="button">重载</button></div><div className="toml"><div className="empty-state">pi.toml 读取与写入 API 尚未提供。</div></div></>}</div></section></div></div>; }
|
|
53
65
|
|
|
54
66
|
function CommandPalette({ commands, onClose, onUse }: { commands: readonly ClientCommand[]; onClose: () => void; onUse: (value: string) => void }) { const [query, setQuery] = useState(""); const visible = commands.filter((command) => `${command.invocationName} ${command.description ?? ""}`.toLowerCase().includes(query.toLowerCase())); return <div className="command-palette" onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}><div className="palette-dialog"><input autoFocus onChange={(event) => setQuery(event.target.value)} placeholder="命令、包、会话、文件" value={query} /><div className="palette-group"><small>COMMANDS · RUNTIME REGISTRY</small>{visible.length ? visible.map((command) => { const invocation = `/${command.invocationName}`; return <button key={`${command.invocationName}:${command.source ?? "runtime"}`} onClick={() => { onUse(invocation); onClose(); }} type="button"><code>{invocation}</code><span>{command.description ?? command.source ?? "由当前运行时注册"}</span></button>; }) : <div className="empty-state">{commands.length ? "没有匹配的命令。" : "当前运行时没有可用的命令注册清单。"}</div>}</div></div></div>; }
|
|
55
67
|
|
|
56
68
|
export function ControlRoomView({ api = createClientApi() }: { api?: ClientApi }) {
|
|
57
|
-
const [data, setData] = useState<RoomData>({ sessions: [], files: [], models: [], providers: [], plugins: [], commands: [] });
|
|
69
|
+
const [data, setData] = useState<RoomData>({ sessions: [], files: [], models: [], providers: [], plugins: [], marketplace: [], marketplaceCapabilities: [], commands: [] });
|
|
58
70
|
const [view, setView] = useState<View>("chat");
|
|
59
71
|
const [page, setPage] = useState<Page>("session");
|
|
60
72
|
const [pluginTab, setPluginTab] = useState<"installed" | "extensions">("installed");
|
|
@@ -65,14 +77,14 @@ export function ControlRoomView({ api = createClientApi() }: { api?: ClientApi }
|
|
|
65
77
|
const [draft, setDraft] = useState("");
|
|
66
78
|
const [search, setSearch] = useState("");
|
|
67
79
|
const [permission, setPermission] = useState(true);
|
|
68
|
-
const refresh = useCallback(async () => { const [status, session, sessions, files, models, providers, plugins, commands] = await Promise.allSettled([api.getStatus(), api.getSession(), api.listSessions(), api.getFiles(), api.listModels(), api.listProviders(), api.listPlugins(), api.listCommands()]); setData((current) => ({ status: status.status === "fulfilled" ? status.value : current.status, session: session.status === "fulfilled" ? session.value : current.session, sessions: sessions.status === "fulfilled" ? sessions.value : current.sessions, files: files.status === "fulfilled" ? files.value : current.files, models: models.status === "fulfilled" ? models.value : current.models, providers: providers.status === "fulfilled" ? providers.value : current.providers, plugins: plugins.status === "fulfilled" ? plugins.value : current.plugins, commands: commands.status === "fulfilled" ? commands.value : current.commands })); }, [api]);
|
|
80
|
+
const refresh = useCallback(async () => { const [status, session, sessions, files, models, providers, plugins, marketplace, commands] = await Promise.allSettled([api.getStatus(), api.getSession(), api.listSessions(), api.getFiles(), api.listModels(), api.listProviders(), api.listPlugins(), api.listMarketplace(), api.listCommands()]); setData((current) => ({ status: status.status === "fulfilled" ? status.value : current.status, session: session.status === "fulfilled" ? session.value : current.session, sessions: sessions.status === "fulfilled" ? sessions.value : current.sessions, files: files.status === "fulfilled" ? files.value : current.files, models: models.status === "fulfilled" ? models.value : current.models, providers: providers.status === "fulfilled" ? providers.value : current.providers, plugins: plugins.status === "fulfilled" ? plugins.value : current.plugins, marketplace: marketplace.status === "fulfilled" ? marketplace.value.items : current.marketplace, marketplaceCapabilities: marketplace.status === "fulfilled" ? marketplace.value.capabilities : current.marketplaceCapabilities, commands: commands.status === "fulfilled" ? commands.value : current.commands })); }, [api]);
|
|
69
81
|
useEffect(() => { void refresh(); const unsubscribe = api.subscribeEvents(() => void refresh()); const timer = window.setInterval(() => void refresh(), 5000); return () => { unsubscribe(); window.clearInterval(timer); }; }, [api, refresh]);
|
|
70
82
|
useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setCommandOpen(false); setSessionMenuOpen(false); setSettings(undefined); } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setCommandOpen(true); } if ((event.metaKey || event.ctrlKey) && event.key === ",") { event.preventDefault(); setSettings("general"); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []);
|
|
71
83
|
const events = data.session?.events ?? [];
|
|
72
84
|
const filteredSessions = data.sessions.filter((session) => !search || value(session.name ?? session.firstMessage, "未命名会话").toLowerCase().includes(search.toLowerCase()));
|
|
73
85
|
const submit = (event: FormEvent) => { event.preventDefault(); const prompt = draft.trim(); if (!prompt) return; setDraft(""); void api.prompt(prompt).then(() => refresh()); };
|
|
74
86
|
const openSession = (session: Record<string, unknown>) => { const path = typeof session.path === "string" ? session.path : ""; if (path) void api.openSession(path).then(refresh); };
|
|
75
|
-
const content = page === "plugins" ? <Plugins plugins={data.plugins} tab={pluginTab} onTab={setPluginTab} onToml={() => setSettings("toml")} /> : view === "chat" ? <section className="view-panel chat-view"><div className="chat-scroll">{data.session?.messages.length ? data.session.messages.map((message, index) => <article className={`turn ${message.role === "user" ? "user" : "text"}`} key={index}>{message.role === "user" ? <div className="user-bubble">{messageText(message)}</div> : <p className="turn-text">{messageText(message)}</p>}</article>) : <Workspace status={data.status} onCreate={() => void api.createSession().then(refresh)} onStarter={setDraft} onToml={() => setSettings("toml")} />}{events.map((event, index) => <RuntimeCard event={event} key={`${value(event.type, "event")}-${index}`} />)}</div><div className="composer-wrap"><div className="context-line"><span>上下文 <b>{data.status ? `${data.status.messages} / ${data.status.model}` : "0 / —"}</b></span><div className={`context-bar ${permission ? "" : "expanded"}`}><i></i><i></i><i></i></div><button onClick={() => setPermission((current) => !current)} type="button">{permission ? "构成" : "收起"}</button></div><form className="composer" onSubmit={submit}><textarea aria-label="Prompt" onChange={(event) => setDraft(event.target.value)} placeholder="描述要做的改动,⌘↵ 发送;@ 引用文件,/ 调用命令" rows={2} value={draft}></textarea><div className="composer-tools"><select aria-label="模型" value={data.status?.model ?? ""} onChange={(event) => { const [provider, model] = event.target.value.split("/"); if (provider && model) void api.selectModel(provider, model); }}>{data.models.map((model) => <option key={`${model.provider}/${model.id}`} value={`${model.provider}/${model.id}`}>{model.name === model.id ? `${model.provider}/${model.id}` : `${model.name} (${model.provider})`}</option>)}</select><button className="tool-chip" onClick={() => setPermission((current) => !current)} type="button">● {permission ? "改动前询问" : "自动允许"}</button><button className="tool-chip" onClick={() => setCommandOpen(true)} type="button">/ 命令</button><span className="composer-hint">⌘↵ 发送 · ⌘K 命令 · ⌃C 中断</span><button className="send-button" type="submit">↑</button></div></form></div></section> : view === "trajectory" ? <Trajectory events={events} onSelect={setDetails} /> : <Files api={api} files={data.files} onDiff={(file) => void api.getFileDiff(file).then((diff) => setDetails({ type: "file_diff", path: diff.path, output: diff.diff }))} onRefresh={() => void refresh()} />;
|
|
87
|
+
const content = page === "plugins" ? <Plugins plugins={data.plugins} tab={pluginTab} onTab={setPluginTab} onToml={() => setSettings("toml")} /> : page === "marketplace" ? <Marketplace plugins={data.marketplace} capabilities={data.marketplaceCapabilities} /> : view === "chat" ? <section className="view-panel chat-view"><div className="chat-scroll">{data.session?.messages.length ? data.session.messages.map((message, index) => <article className={`turn ${message.role === "user" ? "user" : "text"}`} key={index}>{message.role === "user" ? <div className="user-bubble">{messageText(message)}</div> : <p className="turn-text">{messageText(message)}</p>}</article>) : <Workspace status={data.status} onCreate={() => void api.createSession().then(refresh)} onStarter={setDraft} onToml={() => setSettings("toml")} />}{events.map((event, index) => <RuntimeCard event={event} key={`${value(event.type, "event")}-${index}`} />)}</div><div className="composer-wrap"><div className="context-line"><span>上下文 <b>{data.status ? `${data.status.messages} / ${data.status.model}` : "0 / —"}</b></span><div className={`context-bar ${permission ? "" : "expanded"}`}><i></i><i></i><i></i></div><button onClick={() => setPermission((current) => !current)} type="button">{permission ? "构成" : "收起"}</button></div><form className="composer" onSubmit={submit}><textarea aria-label="Prompt" onChange={(event) => setDraft(event.target.value)} placeholder="描述要做的改动,⌘↵ 发送;@ 引用文件,/ 调用命令" rows={2} value={draft}></textarea><div className="composer-tools"><select aria-label="模型" value={data.status?.model ?? ""} onChange={(event) => { const [provider, model] = event.target.value.split("/"); if (provider && model) void api.selectModel(provider, model); }}>{data.models.map((model) => <option key={`${model.provider}/${model.id}`} value={`${model.provider}/${model.id}`}>{model.name === model.id ? `${model.provider}/${model.id}` : `${model.name} (${model.provider})`}</option>)}</select><button className="tool-chip" onClick={() => setPermission((current) => !current)} type="button">● {permission ? "改动前询问" : "自动允许"}</button><button className="tool-chip" onClick={() => setCommandOpen(true)} type="button">/ 命令</button><span className="composer-hint">⌘↵ 发送 · ⌘K 命令 · ⌃C 中断</span><button className="send-button" type="submit">↑</button></div></form></div></section> : view === "trajectory" ? <Trajectory events={events} onSelect={setDetails} /> : <Files api={api} files={data.files} onDiff={(file) => void api.getFileDiff(file).then((diff) => setDetails({ type: "file_diff", path: diff.path, output: diff.diff }))} onRefresh={() => void refresh()} />;
|
|
76
88
|
const groups = sessionGroups(filteredSessions);
|
|
77
|
-
return <div className="app-frame"><aside className="sidebar"><header className="brand-row"><span className="pi-mark">π</span><strong>pi harness</strong><span className="version">0.9.4</span></header><div className="sidebar-actions"><button className="new-session" onClick={() => void api.createSession().then(refresh)} type="button">+ 新建会话</button><input onChange={(event) => setSearch(event.target.value)} placeholder="搜索会话与事件" type="search" value={search} /></div><div className="sidebar-scroll">{groups.length ? groups.map(([label, sessions]) => <div className="session-group" key={label}><div className="group-label">{label}</div>{sessions.map((session, index) => <button className={`session-row ${session.sessionId === data.session?.sessionId ? "active" : ""}`} key={index} onClick={() => openSession(session)} type="button"><span className="session-dot ok"></span><span className="session-copy"><strong>{value(session.name ?? session.firstMessage, "未命名会话")}</strong><small>{value(session.messageCount, "0")} 条消息</small></span></button>)}</div>) : <div className="empty-state">暂无已保存会话</div>}</div><footer className="sidebar-footer"><div className="runtime-cells"><span>model <b>{value(data.status?.model)}</b></span><span>msgs <b>{data.status?.messages ?? 0}</b></span><span>status <b>{value(data.status?.status, "connecting")}</b></span></div><button className={`sidebar-link ${page === "plugins" ? "active" : ""}`} onClick={() => { setPage("plugins"); setSettings(undefined); }} type="button">◈ <span>插件</span><b>{data.plugins.length}</b></button><button className="sidebar-link" onClick={() => setSettings("general")} type="button">⚙ <span>设置</span></button><button className="sidebar-link" onClick={() => setCommandOpen(true)} type="button"><span className="link-glyph">⌘</span><span>命令面板</span><small>⌘K</small></button></footer></aside><section className="main-pane"><header className="main-header"><div className="active-heading"><strong>{page === "plugins" ? "插件" : data.session?.messages.length ? data.session.sessionId.slice(0, 12) : "新会话"}</strong><small>{page === "plugins" ? "Cordis loader 运行时清单" : data.session?.sessionFile ?? "未选择工作区"}</small></div><div className="header-spacer"></div><div className="run-indicator">● {data.status?.status === "running" ? "running · Pi agent" : "idle"}</div><button className="stop-button" disabled={data.status?.status !== "running"} onClick={() => void api.abort().then(refresh)} type="button">停止</button>{page === "session" && <div className="view-tabs">{(["chat", "trajectory", "files"] as const).map((item) => <button className={`view-tab ${view === item ? "active" : ""}`} key={item} onClick={() => setView(item)} type="button">{item === "chat" ? "对话" : item === "trajectory" ? "轨迹" : "产出"}</button>)}</div>}<button className="session-menu" onClick={() => setSessionMenuOpen((current) => !current)} type="button" aria-label="会话操作">⋯</button><button className="details-toggle" onClick={() => setDetails(details ? undefined : {})} type="button">◨ 详情</button><span className={`status-pill ${data.status?.status === "running" ? "running" : "online"}`}>{value(data.status?.status, "connecting")}</span>{sessionMenuOpen && <div className="session-menu-popover"><button className="session-action" onClick={() => void api.createSession().then(refresh)} type="button"><strong>新建会话</strong><small>清空并开始新的运行时会话</small></button><button className="session-action" onClick={() => window.location.reload()} type="button"><strong>刷新会话</strong><small>重新读取运行时状态</small></button><button className="session-action" disabled type="button"><strong>导出事件</strong><small>API 暂未提供导出接口</small></button><button className="session-action" disabled type="button"><strong>删除会话</strong><small>API 暂未提供删除接口</small></button></div>}</header><div className="view-host">{content}</div></section>{details !== undefined && <Details event={Object.keys(details).length ? details : undefined} onClose={() => setDetails(undefined)} onCopy={() => void navigator.clipboard?.writeText(JSON.stringify(details, null, 2))} />}{settings && <Settings api={api} data={data} tab={settings} onTab={setSettings} onClose={() => setSettings(undefined)} />}{commandOpen && <CommandPalette commands={data.commands} onClose={() => setCommandOpen(false)} onUse={setDraft} />}</div>;
|
|
89
|
+
return <div className="app-frame"><aside className="sidebar"><header className="brand-row"><span className="pi-mark">π</span><strong>pi harness</strong><span className="version">0.9.4</span></header><div className="sidebar-actions"><button className="new-session" onClick={() => void api.createSession().then(refresh)} type="button">+ 新建会话</button><input onChange={(event) => setSearch(event.target.value)} placeholder="搜索会话与事件" type="search" value={search} /></div><div className="sidebar-scroll">{groups.length ? groups.map(([label, sessions]) => <div className="session-group" key={label}><div className="group-label">{label}</div>{sessions.map((session, index) => <button className={`session-row ${session.sessionId === data.session?.sessionId ? "active" : ""}`} key={index} onClick={() => openSession(session)} type="button"><span className="session-dot ok"></span><span className="session-copy"><strong>{value(session.name ?? session.firstMessage, "未命名会话")}</strong><small>{value(session.messageCount, "0")} 条消息</small></span></button>)}</div>) : <div className="empty-state">暂无已保存会话</div>}</div><footer className="sidebar-footer"><div className="runtime-cells"><span>model <b>{value(data.status?.model)}</b></span><span>msgs <b>{data.status?.messages ?? 0}</b></span><span>status <b>{value(data.status?.status, "connecting")}</b></span></div><button className={`sidebar-link ${page === "plugins" ? "active" : ""}`} onClick={() => { setPage("plugins"); setSettings(undefined); }} type="button">◈ <span>插件</span><b>{data.plugins.length}</b></button><button className={`sidebar-link ${page === "marketplace" ? "active" : ""}`} onClick={() => { setPage("marketplace"); setSettings(undefined); }} type="button">✦ <span>市场</span><b>{data.marketplace.length}</b></button><button className="sidebar-link" onClick={() => setSettings("general")} type="button">⚙ <span>设置</span></button><button className="sidebar-link" onClick={() => setCommandOpen(true)} type="button"><span className="link-glyph">⌘</span><span>命令面板</span><small>⌘K</small></button></footer></aside><section className="main-pane"><header className="main-header"><div className="active-heading"><strong>{page === "plugins" ? "插件" : page === "marketplace" ? "插件市场" : data.session?.messages.length ? data.session.sessionId.slice(0, 12) : "新会话"}</strong><small>{page === "plugins" ? "Cordis loader 运行时清单" : page === "marketplace" ? "社区目录 · 可审查安装指引" : data.session?.sessionFile ?? "未选择工作区"}</small></div><div className="header-spacer"></div><div className="run-indicator">● {data.status?.status === "running" ? "running · Pi agent" : "idle"}</div><button className="stop-button" disabled={data.status?.status !== "running"} onClick={() => void api.abort().then(refresh)} type="button">停止</button>{page === "session" && <div className="view-tabs">{(["chat", "trajectory", "files"] as const).map((item) => <button className={`view-tab ${view === item ? "active" : ""}`} key={item} onClick={() => setView(item)} type="button">{item === "chat" ? "对话" : item === "trajectory" ? "轨迹" : "产出"}</button>)}</div>}<button className="session-menu" onClick={() => setSessionMenuOpen((current) => !current)} type="button" aria-label="会话操作">⋯</button><button className="details-toggle" onClick={() => setDetails(details ? undefined : {})} type="button">◨ 详情</button><span className={`status-pill ${data.status?.status === "running" ? "running" : "online"}`}>{value(data.status?.status, "connecting")}</span>{sessionMenuOpen && <div className="session-menu-popover"><button className="session-action" onClick={() => void api.createSession().then(refresh)} type="button"><strong>新建会话</strong><small>清空并开始新的运行时会话</small></button><button className="session-action" onClick={() => window.location.reload()} type="button"><strong>刷新会话</strong><small>重新读取运行时状态</small></button><button className="session-action" disabled type="button"><strong>导出事件</strong><small>API 暂未提供导出接口</small></button><button className="session-action" disabled type="button"><strong>删除会话</strong><small>API 暂未提供删除接口</small></button></div>}</header><div className="view-host">{content}</div></section>{details !== undefined && <Details event={Object.keys(details).length ? details : undefined} onClose={() => setDetails(undefined)} onCopy={() => void navigator.clipboard?.writeText(JSON.stringify(details, null, 2))} />}{settings && <Settings api={api} data={data} tab={settings} onTab={setSettings} onClose={() => setSettings(undefined)} />}{commandOpen && <CommandPalette commands={data.commands} onClose={() => setCommandOpen(false)} onUse={setDraft} />}</div>;
|
|
78
90
|
}
|