hololake-system-architecture/product-source/hololake-clean-desktop/src/App.tsx

1108 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from "react";
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { listen } from "@tauri-apps/api/event";
import { Icon } from "./icons";
import * as api from "./runtime";
import type {
KnowledgeDocument,
KnowledgeSummary,
SourceKind,
RuntimeOverview,
SystemSnapshot,
} from "./types";
import {
MarkdownDocument,
documentOutline,
jumpToHeading,
splitFrontmatter,
} from "./modules/knowledge-render";
import { formatHoloLakeTime } from "./time";
type View =
| "channel"
| "knowledge"
| "market"
| "history"
| "portal"
| "settings";
const nav: [View, string, string][] = [
["channel", "我的频道", "channel"],
["knowledge", "光湖知识库", "book"],
["market", "模块商城", "cube"],
["history", "系统演化史", "history"],
["portal", "企业门户", "building"],
["settings", "设置", "settings"],
];
const labels: Record<SourceKind, string> = {
USER_MESSAGE: "用户语言",
PERSONA_RESPONSE: "人格回应",
EXTERNAL_AI_MESSAGE: "外部 AI",
SYSTEM_CONTEXT: "系统环境",
PROTOCOL_EVENT: "协议运行",
AGENT_ACTION: "Agent 执行",
TOOL_RESULT: "工具结果",
SYSTEM_RECEIPT: "系统回执",
};
export default function App() {
const [data, setData] = useState<SystemSnapshot | null>(null),
[runtime, setRuntime] = useState<RuntimeOverview | null>(null),
[view, setView] = useState<View>("channel"),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
const refresh = () =>
api
.snapshot()
.then(setData)
.catch((e) => setError(String(e)));
const refreshRuntime = () =>
api
.runtimeOverview()
.then(setRuntime)
.catch((e) => setError(String(e)));
const refreshAll = () => {
refresh();
refreshRuntime();
};
useEffect(() => {
refreshAll();
if (!("__TAURI_INTERNALS__" in window)) return;
let unlisten: (() => void) | undefined;
listen("hololake-runtime-event", () => refreshAll()).then((dispose) => {
unlisten = dispose;
});
return () => unlisten?.();
}, []);
if (!data)
return (
<div className="boot">
<div className="lake-mark" />
HoloLake
</div>
);
if (!data.channel) return <Onboarding onDone={refreshAll} error={error} />;
return (
<div className="app-shell">
<div className="titlebar">
<div className="traffic">
<i />
<i />
<i />
</div>
<span>HoloLake</span>
<span className="version">
v{data.appVersion.replace("-browser", "")}
</span>
</div>
<aside className="rail">
<div className="brand-orbit">
<span />
</div>
<nav>
{nav.map(([id, label, icon]) => (
<button
key={id}
className={view === id ? "active" : ""}
aria-label={label}
onClick={() => setView(id)}
>
<Icon name={icon} />
<span>{label}</span>
</button>
))}
</nav>
<div className="channel-mini">
<div className="mini-lake" />
<div>
<strong>{data.channel.name}</strong>
<small>{data.channel.channelId}</small>
</div>
</div>
</aside>
<main>
{view === "channel" && (
<Channel
data={data}
runtime={runtime}
refresh={refreshAll}
openKnowledge={() => setView("knowledge")}
/>
)}{" "}
{view === "knowledge" && <Knowledge data={data} refresh={refresh} />}{" "}
{view === "market" && <Market />}{" "}
{view === "history" && <History data={data} />}{" "}
{view === "portal" && <Portal />}{" "}
{view === "settings" && (
<Settings
data={data}
runtime={runtime}
refresh={refreshAll}
busy={busy}
setBusy={setBusy}
setError={setError}
/>
)}
</main>
{error && (
<div className="toast" role="alert">
<span>{error}</span>
<button onClick={() => setError("")}></button>
</div>
)}
</div>
);
}
function Onboarding({ onDone, error }: { onDone: () => void; error: string }) {
const [name, setName] = useState("");
const go = async () => {
if (!name.trim()) return;
await api.createChannel(name.trim());
onDone();
};
return (
<div className="onboard">
<div className="stars" />
<section>
<div className="origin-light" />
<h1></h1>
<p>
Git
</p>
<label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && go()}
placeholder="例如:我的光湖"
autoFocus
/>
</label>
<button onClick={go} disabled={!name.trim()}>
</button>
{error && <small>{error}</small>}
</section>
<footer>GH-AIOS · · </footer>
</div>
);
}
function Channel({
data,
runtime,
refresh,
openKnowledge,
}: {
data: SystemSnapshot;
runtime: RuntimeOverview | null;
refresh: () => void;
openKnowledge: () => void;
}) {
const [text, setText] = useState("");
const send = async () => {
if (!text.trim()) return;
await api.submitMessage(text.trim());
setText("");
refresh();
};
return (
<div className="page channel-page">
<header>
<div>
<h1>{data.channel?.name}</h1>
<p></p>
</div>
<button className="soft" onClick={openKnowledge}>
</button>
</header>
<div className="channel-grid">
<div className="channel-primary">
<section className="conversation">
<div className="welcome">
<div className="lake-mark" />
<h2></h2>
<p>
</p>
</div>
{data.timeline
.filter(
(e) =>
e.sourceKind === "USER_MESSAGE" ||
e.sourceKind === "PERSONA_RESPONSE" ||
e.sourceKind === "EXTERNAL_AI_MESSAGE",
)
.slice(0, 8)
.reverse()
.map((e) => (
<article
className={
e.sourceKind === "USER_MESSAGE"
? "human"
: e.sourceKind === "PERSONA_RESPONSE"
? "persona"
: "external-ai"
}
key={e.eventId}
>
<span>{labels[e.sourceKind]}</span>
<p>{e.content}</p>
</article>
))}
</section>
{runtime &&
runtime.proposals.some(
(proposal) => proposal.state === "PENDING_HUMAN_APPROVAL",
) && (
<section className="approval-queue">
<h2> Agent </h2>
{runtime.proposals
.filter(
(proposal) => proposal.state === "PENDING_HUMAN_APPROVAL",
)
.map((proposal) => (
<article key={proposal.proposalId}>
<div>
<strong>{proposal.gir.programId}</strong>
<p>
{proposal.gir.actions
.map((action) => action.operation)
.join(" → ")}
</p>
<small>
{proposal.clientId} · {proposal.proposalId}
</small>
</div>
<div>
<button
className="soft"
onClick={async () => {
await api.rejectProposal(proposal.proposalId);
refresh();
}}
>
</button>
<button
onClick={async () => {
await api.approveProposal(proposal.proposalId);
refresh();
}}
>
</button>
</div>
</article>
))}
</section>
)}
<ReceiptStream data={data} />
<div className="composer">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`${data.channel?.name}说点什么…`}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
/>
<button onClick={send} aria-label="发送">
</button>
<small>Enter · Shift + Enter </small>
</div>
</div>
<aside className="channel-context" aria-label="频道运行状态">
<article>
<h2></h2>
<strong>{data.channel?.name}</strong>
<p>{data.channel?.channelId}</p>
</article>
<article>
<h2> Git</h2>
<strong></strong>
<p className="path">{data.channel?.privateGitPath}</p>
</article>
<article>
<h2></h2>
<strong>
{runtime?.persona.personas[0]?.displayName ?? "尚未建立"}
</strong>
<p>
{runtime?.persona.personas[0]?.state ?? "不会自动生成默认人格"}
</p>
</article>
<article>
<h2>GLP </h2>
<strong>
{runtime?.realtime.state ?? "正在启动"} ·{" "}
{runtime?.realtime.connectedClients ?? 0}
</strong>
<p>{runtime?.realtime.endpoint ?? "仅监听本机回环地址"}</p>
</article>
<article>
<h2></h2>
<strong>
{data.publicDistributionState ===
"SIGNED_UPDATE_FEED_READY_NO_RELEASE"
? "更新入口在线 · 暂无发行"
: data.publicDistributionState}
</strong>
<p> Git </p>
</article>
</aside>
</div>
</div>
);
}
function ReceiptStream({ data }: { data: SystemSnapshot }) {
const rows = data.timeline
.filter(
(e) =>
e.sourceKind !== "USER_MESSAGE" &&
e.sourceKind !== "PERSONA_RESPONSE" &&
e.sourceKind !== "EXTERNAL_AI_MESSAGE",
)
.slice(0, 5);
return (
<section className="receipt-stream">
<h2></h2>
{rows.length === 0 ? (
<p className="empty">
</p>
) : (
rows.map((e) => (
<div
className={`receipt ${e.sourceKind.toLowerCase()}`}
key={e.eventId}
>
<span className="kind">{labels[e.sourceKind]}</span>
<strong>{e.title}</strong>
<p>{e.content}</p>
<time>
{formatHoloLakeTime(e.occurredAt)}
</time>
</div>
))
)}
</section>
);
}
const tagTints = ["tag-sky", "tag-violet", "tag-amber", "tag-mint", "tag-rose"];
function tagTint(tag: string) {
let hash = 0;
for (const char of tag) hash = (hash * 31 + (char.codePointAt(0) || 0)) % 997;
return tagTints[hash % tagTints.length];
}
interface KnowledgeTreeNode {
name: string;
key: string;
folders: Map<string, KnowledgeTreeNode>;
documents: KnowledgeSummary[];
}
function buildKnowledgeTree(documents: KnowledgeSummary[]) {
const root: KnowledgeTreeNode = {
name: "知识",
key: "",
folders: new Map(),
documents: [],
};
for (const document of documents) {
const segments = document.path.split("/").filter(Boolean);
segments.pop();
let cursor = root;
for (const segment of segments) {
const key = cursor.key ? `${cursor.key}/${segment}` : segment;
if (!cursor.folders.has(segment))
cursor.folders.set(segment, {
name: segment,
key,
folders: new Map(),
documents: [],
});
cursor = cursor.folders.get(segment)!;
}
cursor.documents.push(document);
}
return root;
}
function treeCount(node: KnowledgeTreeNode): number {
return (
node.documents.length +
[...node.folders.values()].reduce((sum, child) => sum + treeCount(child), 0)
);
}
function KnowledgeTree({
node,
depth,
expanded,
active,
onToggle,
onOpen,
}: {
node: KnowledgeTreeNode;
depth: number;
expanded: Set<string>;
active: string | null;
onToggle: (key: string) => void;
onOpen: (path: string) => void;
}) {
const folders = [...node.folders.values()].sort((a, b) =>
a.name.localeCompare(b.name, "zh-CN"),
);
const documents = [...node.documents].sort((a, b) =>
a.title.localeCompare(b.title, "zh-CN"),
);
return (
<>
{folders.map((folder) => {
const open = depth === 0 || expanded.has(folder.key);
return (
<div className="tree-branch" key={folder.key}>
<button
className="tree-folder"
style={{ paddingLeft: 10 + depth * 13 }}
onClick={() => onToggle(folder.key)}
>
<span className={open ? "tree-chevron open" : "tree-chevron"}>
</span>
<Icon name="folder" />
<span>{folder.name}</span>
<em>{treeCount(folder)}</em>
</button>
{open && (
<KnowledgeTree
node={folder}
depth={depth + 1}
expanded={expanded}
active={active}
onToggle={onToggle}
onOpen={onOpen}
/>
)}
</div>
);
})}
{documents.map((document) => (
<button
className={
active === document.path ? "tree-document active" : "tree-document"
}
style={{ paddingLeft: 28 + depth * 13 }}
key={document.path}
onClick={() => onOpen(document.path)}
>
<Icon name="file" />
<span>{document.title}</span>
</button>
))}
</>
);
}
function Knowledge({
data,
refresh,
}: {
data: SystemSnapshot;
refresh: () => void;
}) {
const [selected, setSelected] = useState<string | null>(
data.documents[0]?.path ?? null,
),
[doc, setDoc] = useState<KnowledgeDocument | null>(null),
[body, setBody] = useState(""),
[query, setQuery] = useState(""),
[newTitle, setNewTitle] = useState<string | null>(null),
[editing, setEditing] = useState(false),
[inspectorOpen, setInspectorOpen] = useState(true),
[expanded, setExpanded] = useState<Set<string>>(new Set(["导入"])),
[activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const headingScrollTimer = useRef<number | null>(null);
useEffect(() => {
if (selected)
api.readDocument(selected).then((d) => {
setDoc(d);
setBody(d.body);
setEditing(false);
setActiveHeadingId(null);
});
}, [selected]);
const list = useMemo(
() =>
data.documents.filter((d) =>
d.title.toLowerCase().includes(query.toLowerCase()),
),
[data.documents, query],
);
const tree = useMemo(() => buildKnowledgeTree(list), [list]);
const create = async () => {
const title = newTitle?.trim();
if (title) {
await api.createDocument(title);
setNewTitle(null);
await refresh();
setSelected(`${title.replace(/[\\/:*?\"<>|]/g, "-")}.md`);
}
};
const save = async () => {
if (doc) {
await api.saveDocument(doc.path, body);
setDoc({ ...doc, body });
setEditing(false);
refresh();
}
};
const del = async () => {
if (doc && confirm(`把“${doc.title}”移入回收站?`)) {
await api.deleteDocument(doc.path);
setSelected(null);
setDoc(null);
refresh();
}
};
const parsed = useMemo(() => splitFrontmatter(doc?.body ?? ""), [doc?.body]);
const outline = useMemo(() => documentOutline(doc?.body ?? ""), [doc?.body]);
const stats = useMemo(() => {
const chars = parsed.content.replace(/\s/g, "").length;
return { chars, minutes: Math.max(1, Math.ceil(chars / 400)) };
}, [parsed.content]);
useEffect(() => {
if (!activeHeadingId) return;
document
.querySelector(".outline-list button.active")
?.scrollIntoView({ block: "nearest" });
}, [activeHeadingId]);
const handleReaderScroll = (event: React.UIEvent<HTMLDivElement>) => {
const container = event.currentTarget;
if (headingScrollTimer.current !== null) return;
headingScrollTimer.current = window.setTimeout(() => {
headingScrollTimer.current = null;
const containerTop = container.getBoundingClientRect().top;
let current: string | null = null;
for (const heading of Array.from(
container.querySelectorAll<HTMLElement>(
"h1[id], h2[id], h3[id], h4[id]",
),
)) {
if (heading.getBoundingClientRect().top - containerTop <= 28)
current = heading.id;
else break;
}
setActiveHeadingId(current);
}, 100);
};
const openWiki = (target: string) => {
const normalized = target.replace(/[\s·•・\-_|*`]/g, "").toLowerCase();
const hit = data.documents.find(
(item) =>
item.title.replace(/[\s·•・\-_|*`]/g, "").toLowerCase() === normalized,
);
if (hit) setSelected(hit.path);
};
return (
<div className="page knowledge-page">
<header>
<div>
<h1></h1>
<p> · HLP-MOD-KB-0001</p>
</div>
<div className="actions">
<button
className="soft"
onClick={async () => {
await api.importFolder();
refresh();
}}
>
</button>
<button onClick={() => setNewTitle("")}></button>
</div>
</header>
<div
className={`knowledge-workspace complete ${inspectorOpen ? "" : "inspector-closed"}`}
>
<aside className="knowledge-browser">
<div className="browser-title">
<strong></strong>
<span>{data.documents.length} </span>
</div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索知识库"
/>
{list.length === 0 ? (
<p className="empty"></p>
) : (
<div className="knowledge-tree">
<KnowledgeTree
node={tree}
depth={0}
expanded={expanded}
active={selected}
onToggle={(key) =>
setExpanded((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
})
}
onOpen={setSelected}
/>
</div>
)}
</aside>
<section className="document-workspace">
{doc ? (
<>
<div className="editor-tools">
<strong>{doc.title}</strong>
<span />
<button onClick={() => setInspectorOpen((value) => !value)}>
{inspectorOpen ? "收起大纲" : "展开大纲"}
</button>
<button onClick={() => api.exportDocument(doc.path)}>
</button>
{editing ? (
<>
<button
onClick={() => {
setBody(doc.body);
setEditing(false);
}}
>
</button>
<button onClick={save}></button>
</>
) : (
<>
<button className="danger" onClick={del}>
</button>
<button onClick={() => setEditing(true)}></button>
</>
)}
</div>
{editing ? (
<textarea
className="editor"
aria-label="Markdown 编辑器"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
) : (
<div className="document-scroll" onScroll={handleReaderScroll}>
<header className="reader-heading">
<h1>{doc.title}</h1>
<p>
{stats.chars.toLocaleString()} · {stats.minutes}{" "}
</p>
{parsed.tags.length > 0 && (
<div className="meta-tags">
{parsed.tags.map((tag) => (
<span key={tag} className={tagTint(tag)}>
{tag}
</span>
))}
</div>
)}
<div className="meta-properties">
{Object.entries(parsed.metadata)
.filter(([key]) => key !== "tags")
.slice(0, 6)
.map(([key, value]) => (
<span key={key}>
<b>{key}</b>
{value}
</span>
))}
</div>
</header>
<MarkdownDocument
body={doc.body}
knownTitles={data.documents.map((item) => item.title)}
onWiki={openWiki}
/>
</div>
)}
</>
) : (
<div className="welcome">
<Icon name="book" />
<h2></h2>
<p></p>
<button onClick={() => setNewTitle("")}></button>
</div>
)}
</section>
{inspectorOpen && (
<aside className="document-inspector">
<header>
<strong></strong>
<span></span>
</header>
{doc ? (
<>
<nav className="outline-list">
{outline.length ? (
outline.map((item) => (
<button
className={activeHeadingId === item.id ? "active" : ""}
key={item.id}
style={{ paddingLeft: 12 + (item.level - 1) * 10 }}
onClick={() => {
jumpToHeading(item.id);
setActiveHeadingId(item.id);
}}
>
{item.title}
</button>
))
) : (
<p></p>
)}
</nav>
<dl className="document-evidence">
<div>
<dt></dt>
<dd> Git</dd>
</div>
<div>
<dt></dt>
<dd>{doc.path}</dd>
</div>
<div>
<dt></dt>
<dd>{doc.sha256.slice(0, 16)}</dd>
</div>
<div>
<dt></dt>
<dd> · </dd>
</div>
</dl>
</>
) : (
<p className="empty"></p>
)}
</aside>
)}
</div>
{newTitle !== null && (
<div className="modal-backdrop" onMouseDown={() => setNewTitle(null)}>
<form
className="modal"
onSubmit={(e) => {
e.preventDefault();
create();
}}
onMouseDown={(e) => e.stopPropagation()}
>
<h2></h2>
<p> Markdown Git</p>
<label>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="例如:第一条知识"
autoFocus
/>
</label>
<div>
<button
type="button"
className="soft"
onClick={() => setNewTitle(null)}
>
</button>
<button type="submit" disabled={!newTitle.trim()}>
</button>
</div>
</form>
</div>
)}
</div>
);
}
function Market() {
return (
<div className="page simple">
<header>
<div>
<h1></h1>
<p>使</p>
</div>
</header>
<section className="split-list">
<div>
<h2>使</h2>
<p></p>
<span></span>
</div>
<div>
<h2>使</h2>
<p></p>
<span></span>
</div>
</section>
</div>
);
}
function History({ data }: { data: SystemSnapshot }) {
return (
<div className="page simple">
<header>
<div>
<h1></h1>
<p></p>
</div>
</header>
<section className="timeline-list">
{data.timeline.map((e) => (
<article key={e.eventId}>
<time>{formatHoloLakeTime(e.occurredAt, true)}</time>
<div>
<strong>
{labels[e.sourceKind]} · {e.title}
</strong>
<p>{e.content}</p>
</div>
</article>
))}
</section>
</div>
);
}
function Portal() {
return (
<div className="page simple">
<header>
<div>
<h1></h1>
<p></p>
</div>
</header>
<section className="portal">
<div className="origin-light" />
<h2></h2>
<p>
HoloLake
</p>
<div className="portal-actions">
<a href="https://guanghu.chat" target="_blank" rel="noreferrer">
</a>
<a
href="https://guanghu.chat/code/user/login"
target="_blank"
rel="noreferrer"
>
</a>
</div>
</section>
</div>
);
}
function Settings({
data,
runtime,
refresh,
busy,
setBusy,
setError,
}: {
data: SystemSnapshot;
runtime: RuntimeOverview | null;
refresh: () => void;
busy: boolean;
setBusy: (v: boolean) => void;
setError: (v: string) => void;
}) {
const [bridgeName, setBridgeName] = useState(""),
[personaName, setPersonaName] = useState(""),
[runtimeMessage, setRuntimeMessage] = useState("");
const update = async () => {
setBusy(true);
try {
const u = await check();
if (!u) throw new Error("当前已是最新版本,或公共发行尚未发布。");
if (confirm(`发现 HoloLake ${u.version},现在安装?`)) {
await u.downloadAndInstall();
await relaunch();
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<div className="page simple">
<header>
<div>
<h1></h1>
<p> AI</p>
</div>
</header>
<section className="settings-list">
<article>
<div>
<h2> Git</h2>
<p>{data.channel?.privateGitPath}</p>
</div>
<span className="status ok"></span>
</article>
<article>
<div>
<h2></h2>
<p>{data.publicUpdateEndpoint}</p>
</div>
<button className="soft" onClick={update} disabled={busy}>
{busy ? "正在检查…" : "检查更新"}
</button>
</article>
<article className="bridge">
<div>
<h2> AI</h2>
<p>
{runtime?.realtime.endpoint ?? "本机实时桥正在启动"} ·{" "}
{runtime?.realtime.connectedClients ?? 0}{" "}
</p>
</div>
<div>
<input
value={bridgeName}
onChange={(e) => setBridgeName(e.target.value)}
placeholder="给这个 AI 入口起名"
/>
<button
onClick={async () => {
if (bridgeName.trim()) {
await api.registerBridge(bridgeName.trim());
setBridgeName("");
refresh();
}
}}
>
</button>
</div>
</article>
<article className="bridge">
<div>
<h2></h2>
<p>
{runtime?.persona.personas.length
? `${runtime.persona.personas.length} 个本频道试用人格;既有人格验证数 ${runtime.persona.verifiedExistingPersonaCount}`
: "建立一个本频道可逆试用人格,再由外部 AI 宿主实时承载。"}
</p>
</div>
<div>
<input
value={personaName}
onChange={(e) => setPersonaName(e.target.value)}
placeholder="给试用人格起名"
/>
<button
onClick={async () => {
if (personaName.trim()) {
const persona = await api.registerPersona(personaName.trim());
setPersonaName("");
setRuntimeMessage(
`${persona.displayName} 已建立;尚未验证为既有历史人格。`,
);
refresh();
}
}}
>
</button>
</div>
</article>
<article>
<div>
<h2></h2>
<p>
{runtimeMessage ||
"先建立一个外部 AI 入口,然后复制本机 GLP 接入命令给外部编程 AI。"}
</p>
</div>
<button
className="soft"
disabled={!data.externalAiBridges.length}
onClick={async () => {
const invitation = await api.realtimeInvitation();
const bridge = data.externalAiBridges[0];
const persona = runtime?.persona.personas[0];
const command = `${invitation.connectorCommand} --bridge-id ${bridge.bridgeId}${persona ? ` --persona-id ${persona.personaId}` : ""}`;
await navigator.clipboard.writeText(command);
setRuntimeMessage(
"实时接入命令已复制。令牌只在本机 descriptor 文件中。",
);
}}
>
</button>
</article>
{data.externalAiBridges.map((b) => (
<article key={b.bridgeId}>
<div>
<h2>{b.displayName}</h2>
<p>
{b.bridgeId} ·{" "}
{runtime?.realtime.protocol ?? "GLP_LOCAL_REALTIME/1"}
</p>
</div>
<span className="status">{b.state}</span>
</article>
))}
{runtime?.persona.personas.map((persona) => (
<article key={persona.personaId}>
<div>
<h2>{persona.displayName}</h2>
<p>
{persona.personaId} · {persona.state}
</p>
</div>
<button
className="soft danger"
onClick={async () => {
if (confirm(`删除试用人格 ${persona.personaId}`)) {
await api.deleteTrialPersona(persona.personaId);
refresh();
}
}}
>
</button>
</article>
))}
</section>
</div>
);
}