2026-09-03 16:40:56 +08:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
2026-09-03 15:35:33 +08:00
|
|
|
|
import { check } from "@tauri-apps/plugin-updater";
|
|
|
|
|
|
import { relaunch } from "@tauri-apps/plugin-process";
|
2026-09-03 19:44:19 +08:00
|
|
|
|
import { listen } from "@tauri-apps/api/event";
|
2026-09-03 15:35:33 +08:00
|
|
|
|
import { Icon } from "./icons";
|
|
|
|
|
|
import * as api from "./runtime";
|
2026-09-03 16:40:56 +08:00
|
|
|
|
import type {
|
|
|
|
|
|
KnowledgeDocument,
|
|
|
|
|
|
KnowledgeSummary,
|
|
|
|
|
|
SourceKind,
|
2026-09-03 19:44:19 +08:00
|
|
|
|
RuntimeOverview,
|
2026-09-03 16:40:56 +08:00
|
|
|
|
SystemSnapshot,
|
|
|
|
|
|
} from "./types";
|
2026-09-03 16:25:43 +08:00
|
|
|
|
import {
|
|
|
|
|
|
MarkdownDocument,
|
|
|
|
|
|
documentOutline,
|
|
|
|
|
|
jumpToHeading,
|
|
|
|
|
|
splitFrontmatter,
|
|
|
|
|
|
} from "./modules/knowledge-render";
|
2026-09-03 21:14:45 +08:00
|
|
|
|
import { formatHoloLakeTime } from "./time";
|
2026-09-03 15:35:33 +08:00
|
|
|
|
|
|
|
|
|
|
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: "人格回应",
|
2026-09-03 19:44:19 +08:00
|
|
|
|
EXTERNAL_AI_MESSAGE: "外部 AI",
|
2026-09-03 15:35:33 +08:00
|
|
|
|
SYSTEM_CONTEXT: "系统环境",
|
|
|
|
|
|
PROTOCOL_EVENT: "协议运行",
|
|
|
|
|
|
AGENT_ACTION: "Agent 执行",
|
|
|
|
|
|
TOOL_RESULT: "工具结果",
|
|
|
|
|
|
SYSTEM_RECEIPT: "系统回执",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default function App() {
|
|
|
|
|
|
const [data, setData] = useState<SystemSnapshot | null>(null),
|
2026-09-03 19:44:19 +08:00
|
|
|
|
[runtime, setRuntime] = useState<RuntimeOverview | null>(null),
|
2026-09-03 15:35:33 +08:00
|
|
|
|
[view, setView] = useState<View>("channel"),
|
|
|
|
|
|
[error, setError] = useState(""),
|
|
|
|
|
|
[busy, setBusy] = useState(false);
|
|
|
|
|
|
const refresh = () =>
|
|
|
|
|
|
api
|
|
|
|
|
|
.snapshot()
|
|
|
|
|
|
.then(setData)
|
|
|
|
|
|
.catch((e) => setError(String(e)));
|
2026-09-03 19:44:19 +08:00
|
|
|
|
const refreshRuntime = () =>
|
|
|
|
|
|
api
|
|
|
|
|
|
.runtimeOverview()
|
|
|
|
|
|
.then(setRuntime)
|
|
|
|
|
|
.catch((e) => setError(String(e)));
|
|
|
|
|
|
const refreshAll = () => {
|
2026-09-03 15:35:33 +08:00
|
|
|
|
refresh();
|
2026-09-03 19:44:19 +08:00
|
|
|
|
refreshRuntime();
|
|
|
|
|
|
};
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
refreshAll();
|
|
|
|
|
|
if (!("__TAURI_INTERNALS__" in window)) return;
|
|
|
|
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
|
|
listen("hololake-runtime-event", () => refreshAll()).then((dispose) => {
|
|
|
|
|
|
unlisten = dispose;
|
|
|
|
|
|
});
|
|
|
|
|
|
return () => unlisten?.();
|
2026-09-03 15:35:33 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
if (!data)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="boot">
|
|
|
|
|
|
<div className="lake-mark" />
|
|
|
|
|
|
正在进入 HoloLake…
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-09-03 19:44:19 +08:00
|
|
|
|
if (!data.channel) return <Onboarding onDone={refreshAll} error={error} />;
|
2026-09-03 15:35:33 +08:00
|
|
|
|
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}
|
2026-09-03 19:44:19 +08:00
|
|
|
|
runtime={runtime}
|
|
|
|
|
|
refresh={refreshAll}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
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}
|
2026-09-03 19:44:19 +08:00
|
|
|
|
runtime={runtime}
|
|
|
|
|
|
refresh={refreshAll}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
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,
|
2026-09-03 19:44:19 +08:00
|
|
|
|
runtime,
|
2026-09-03 15:35:33 +08:00
|
|
|
|
refresh,
|
|
|
|
|
|
openKnowledge,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
data: SystemSnapshot;
|
2026-09-03 19:44:19 +08:00
|
|
|
|
runtime: RuntimeOverview | null;
|
2026-09-03 15:35:33 +08:00
|
|
|
|
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" ||
|
2026-09-03 19:44:19 +08:00
|
|
|
|
e.sourceKind === "PERSONA_RESPONSE" ||
|
|
|
|
|
|
e.sourceKind === "EXTERNAL_AI_MESSAGE",
|
2026-09-03 15:35:33 +08:00
|
|
|
|
)
|
|
|
|
|
|
.slice(0, 8)
|
|
|
|
|
|
.reverse()
|
|
|
|
|
|
.map((e) => (
|
|
|
|
|
|
<article
|
|
|
|
|
|
className={
|
2026-09-03 19:44:19 +08:00
|
|
|
|
e.sourceKind === "USER_MESSAGE"
|
|
|
|
|
|
? "human"
|
|
|
|
|
|
: e.sourceKind === "PERSONA_RESPONSE"
|
|
|
|
|
|
? "persona"
|
|
|
|
|
|
: "external-ai"
|
2026-09-03 15:35:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
key={e.eventId}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{labels[e.sourceKind]}</span>
|
|
|
|
|
|
<p>{e.content}</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</section>
|
2026-09-03 19:44:19 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
<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>
|
2026-09-03 19:44:19 +08:00
|
|
|
|
<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>
|
2026-09-03 15:35:33 +08:00
|
|
|
|
<article>
|
|
|
|
|
|
<h2>公共更新</h2>
|
|
|
|
|
|
<strong>
|
2026-09-03 16:25:43 +08:00
|
|
|
|
{data.publicDistributionState ===
|
|
|
|
|
|
"SIGNED_UPDATE_FEED_READY_NO_RELEASE"
|
|
|
|
|
|
? "更新入口在线 · 暂无发行"
|
|
|
|
|
|
: data.publicDistributionState}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
</strong>
|
|
|
|
|
|
<p>只接收签名发行,不自动执行普通 Git 拉取。</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ReceiptStream({ data }: { data: SystemSnapshot }) {
|
|
|
|
|
|
const rows = data.timeline
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
(e) =>
|
2026-09-03 19:44:19 +08:00
|
|
|
|
e.sourceKind !== "USER_MESSAGE" &&
|
|
|
|
|
|
e.sourceKind !== "PERSONA_RESPONSE" &&
|
|
|
|
|
|
e.sourceKind !== "EXTERNAL_AI_MESSAGE",
|
2026-09-03 15:35:33 +08:00
|
|
|
|
)
|
|
|
|
|
|
.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>
|
2026-09-03 21:14:45 +08:00
|
|
|
|
{formatHoloLakeTime(e.occurredAt)}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
</time>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 16:40:56 +08:00
|
|
|
|
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>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 15:35:33 +08:00
|
|
|
|
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(""),
|
2026-09-03 16:25:43 +08:00
|
|
|
|
[newTitle, setNewTitle] = useState<string | null>(null),
|
|
|
|
|
|
[editing, setEditing] = useState(false),
|
2026-09-03 16:40:56 +08:00
|
|
|
|
[inspectorOpen, setInspectorOpen] = useState(true),
|
|
|
|
|
|
[expanded, setExpanded] = useState<Set<string>>(new Set(["导入"])),
|
|
|
|
|
|
[activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
|
|
|
|
|
const headingScrollTimer = useRef<number | null>(null);
|
2026-09-03 15:35:33 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selected)
|
|
|
|
|
|
api.readDocument(selected).then((d) => {
|
|
|
|
|
|
setDoc(d);
|
|
|
|
|
|
setBody(d.body);
|
2026-09-03 16:25:43 +08:00
|
|
|
|
setEditing(false);
|
2026-09-03 16:40:56 +08:00
|
|
|
|
setActiveHeadingId(null);
|
2026-09-03 15:35:33 +08:00
|
|
|
|
});
|
|
|
|
|
|
}, [selected]);
|
|
|
|
|
|
const list = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
data.documents.filter((d) =>
|
|
|
|
|
|
d.title.toLowerCase().includes(query.toLowerCase()),
|
|
|
|
|
|
),
|
|
|
|
|
|
[data.documents, query],
|
|
|
|
|
|
);
|
2026-09-03 16:40:56 +08:00
|
|
|
|
const tree = useMemo(() => buildKnowledgeTree(list), [list]);
|
2026-09-03 15:35:33 +08:00
|
|
|
|
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);
|
2026-09-03 16:25:43 +08:00
|
|
|
|
setDoc({ ...doc, body });
|
|
|
|
|
|
setEditing(false);
|
2026-09-03 15:35:33 +08:00
|
|
|
|
refresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
const del = async () => {
|
|
|
|
|
|
if (doc && confirm(`把“${doc.title}”移入回收站?`)) {
|
|
|
|
|
|
await api.deleteDocument(doc.path);
|
|
|
|
|
|
setSelected(null);
|
|
|
|
|
|
setDoc(null);
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-09-03 16:25:43 +08:00
|
|
|
|
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;
|
2026-09-03 16:40:56 +08:00
|
|
|
|
return { chars, minutes: Math.max(1, Math.ceil(chars / 400)) };
|
2026-09-03 16:25:43 +08:00
|
|
|
|
}, [parsed.content]);
|
2026-09-03 16:40:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-09-03 16:25:43 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-09-03 15:35:33 +08:00
|
|
|
|
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>
|
2026-09-03 16:25:43 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className={`knowledge-workspace complete ${inspectorOpen ? "" : "inspector-closed"}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<aside className="knowledge-browser">
|
|
|
|
|
|
<div className="browser-title">
|
|
|
|
|
|
<strong>知识目录</strong>
|
|
|
|
|
|
<span>{data.documents.length} 篇</span>
|
|
|
|
|
|
</div>
|
2026-09-03 15:35:33 +08:00
|
|
|
|
<input
|
|
|
|
|
|
value={query}
|
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
|
|
|
|
placeholder="搜索知识库"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{list.length === 0 ? (
|
|
|
|
|
|
<p className="empty">暂无知识页</p>
|
|
|
|
|
|
) : (
|
2026-09-03 16:40:56 +08:00
|
|
|
|
<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>
|
2026-09-03 15:35:33 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</aside>
|
2026-09-03 16:25:43 +08:00
|
|
|
|
<section className="document-workspace">
|
2026-09-03 15:35:33 +08:00
|
|
|
|
{doc ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="editor-tools">
|
|
|
|
|
|
<strong>{doc.title}</strong>
|
|
|
|
|
|
<span />
|
2026-09-03 16:25:43 +08:00
|
|
|
|
<button onClick={() => setInspectorOpen((value) => !value)}>
|
|
|
|
|
|
{inspectorOpen ? "收起大纲" : "展开大纲"}
|
|
|
|
|
|
</button>
|
2026-09-03 15:35:33 +08:00
|
|
|
|
<button onClick={() => api.exportDocument(doc.path)}>
|
|
|
|
|
|
下载
|
|
|
|
|
|
</button>
|
2026-09-03 16:25:43 +08:00
|
|
|
|
{editing ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setBody(doc.body);
|
|
|
|
|
|
setEditing(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onClick={save}>保存</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button className="danger" onClick={del}>
|
|
|
|
|
|
删除
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onClick={() => setEditing(true)}>编辑</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
</div>
|
2026-09-03 16:25:43 +08:00
|
|
|
|
{editing ? (
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="editor"
|
|
|
|
|
|
aria-label="Markdown 编辑器"
|
|
|
|
|
|
value={body}
|
|
|
|
|
|
onChange={(e) => setBody(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-09-03 16:40:56 +08:00
|
|
|
|
<div className="document-scroll" onScroll={handleReaderScroll}>
|
2026-09-03 16:25:43 +08:00
|
|
|
|
<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) => (
|
2026-09-03 16:40:56 +08:00
|
|
|
|
<span key={tag} className={tagTint(tag)}>
|
|
|
|
|
|
{tag}
|
|
|
|
|
|
</span>
|
2026-09-03 16:25:43 +08:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-09-03 16:40:56 +08:00
|
|
|
|
<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>
|
2026-09-03 16:25:43 +08:00
|
|
|
|
</header>
|
|
|
|
|
|
<MarkdownDocument
|
|
|
|
|
|
body={doc.body}
|
|
|
|
|
|
knownTitles={data.documents.map((item) => item.title)}
|
|
|
|
|
|
onWiki={openWiki}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="welcome">
|
|
|
|
|
|
<Icon name="book" />
|
|
|
|
|
|
<h2>从第一条知识开始</h2>
|
|
|
|
|
|
<p>新建页面,或导入一个外部文件夹。原文件不会被改写。</p>
|
|
|
|
|
|
<button onClick={() => setNewTitle("")}>新建知识页</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
2026-09-03 16:25:43 +08:00
|
|
|
|
{inspectorOpen && (
|
|
|
|
|
|
<aside className="document-inspector">
|
|
|
|
|
|
<header>
|
|
|
|
|
|
<strong>页面大纲</strong>
|
|
|
|
|
|
<span>来源与证据</span>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
{doc ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<nav className="outline-list">
|
|
|
|
|
|
{outline.length ? (
|
|
|
|
|
|
outline.map((item) => (
|
|
|
|
|
|
<button
|
2026-09-03 16:40:56 +08:00
|
|
|
|
className={activeHeadingId === item.id ? "active" : ""}
|
2026-09-03 16:25:43 +08:00
|
|
|
|
key={item.id}
|
|
|
|
|
|
style={{ paddingLeft: 12 + (item.level - 1) * 10 }}
|
2026-09-03 16:40:56 +08:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
jumpToHeading(item.id);
|
|
|
|
|
|
setActiveHeadingId(item.id);
|
|
|
|
|
|
}}
|
2026-09-03 16:25:43 +08:00
|
|
|
|
>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
</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}>
|
2026-09-03 21:14:45 +08:00
|
|
|
|
<time>{formatHoloLakeTime(e.occurredAt, true)}</time>
|
2026-09-03 15:35:33 +08:00
|
|
|
|
<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>
|
2026-09-03 16:13:17 +08:00
|
|
|
|
<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>
|
2026-09-03 15:35:33 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
function Settings({
|
|
|
|
|
|
data,
|
2026-09-03 19:44:19 +08:00
|
|
|
|
runtime,
|
2026-09-03 15:35:33 +08:00
|
|
|
|
refresh,
|
|
|
|
|
|
busy,
|
|
|
|
|
|
setBusy,
|
|
|
|
|
|
setError,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
data: SystemSnapshot;
|
2026-09-03 19:44:19 +08:00
|
|
|
|
runtime: RuntimeOverview | null;
|
2026-09-03 15:35:33 +08:00
|
|
|
|
refresh: () => void;
|
|
|
|
|
|
busy: boolean;
|
|
|
|
|
|
setBusy: (v: boolean) => void;
|
|
|
|
|
|
setError: (v: string) => void;
|
|
|
|
|
|
}) {
|
2026-09-03 19:44:19 +08:00
|
|
|
|
const [bridgeName, setBridgeName] = useState(""),
|
|
|
|
|
|
[personaName, setPersonaName] = useState(""),
|
|
|
|
|
|
[runtimeMessage, setRuntimeMessage] = useState("");
|
2026-09-03 15:35:33 +08:00
|
|
|
|
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>
|
2026-09-03 19:44:19 +08:00
|
|
|
|
<p>
|
|
|
|
|
|
{runtime?.realtime.endpoint ?? "本机实时桥正在启动"} ·{" "}
|
|
|
|
|
|
{runtime?.realtime.connectedClients ?? 0}{" "}
|
|
|
|
|
|
个实时连接。连接不授予人格或执行权。
|
|
|
|
|
|
</p>
|
2026-09-03 15:35:33 +08:00
|
|
|
|
</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>
|
2026-09-03 19:44:19 +08:00
|
|
|
|
<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>
|
2026-09-03 15:35:33 +08:00
|
|
|
|
{data.externalAiBridges.map((b) => (
|
|
|
|
|
|
<article key={b.bridgeId}>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>{b.displayName}</h2>
|
|
|
|
|
|
<p>
|
2026-09-03 19:44:19 +08:00
|
|
|
|
{b.bridgeId} ·{" "}
|
|
|
|
|
|
{runtime?.realtime.protocol ?? "GLP_LOCAL_REALTIME/1"}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="status">{b.state}</span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
))}
|
2026-09-03 19:44:19 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
))}
|
2026-09-03 15:35:33 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|