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

1111 lines
34 KiB
TypeScript
Raw Normal View History

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";
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>
{new Date(e.occurredAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</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>{new Date(e.occurredAt).toLocaleString("zh-CN")}</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>
);
}