629 lines
18 KiB
TypeScript
629 lines
18 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { check } from "@tauri-apps/plugin-updater";
|
||
import { relaunch } from "@tauri-apps/plugin-process";
|
||
import { Icon } from "./icons";
|
||
import * as api from "./runtime";
|
||
import type { KnowledgeDocument, SourceKind, SystemSnapshot } from "./types";
|
||
|
||
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: "人格回应",
|
||
SYSTEM_CONTEXT: "系统环境",
|
||
PROTOCOL_EVENT: "协议运行",
|
||
AGENT_ACTION: "Agent 执行",
|
||
TOOL_RESULT: "工具结果",
|
||
SYSTEM_RECEIPT: "系统回执",
|
||
};
|
||
|
||
export default function App() {
|
||
const [data, setData] = useState<SystemSnapshot | 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)));
|
||
useEffect(() => {
|
||
refresh();
|
||
}, []);
|
||
if (!data)
|
||
return (
|
||
<div className="boot">
|
||
<div className="lake-mark" />
|
||
正在进入 HoloLake…
|
||
</div>
|
||
);
|
||
if (!data.channel) return <Onboarding onDone={refresh} 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}
|
||
refresh={refresh}
|
||
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}
|
||
refresh={refresh}
|
||
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,
|
||
refresh,
|
||
openKnowledge,
|
||
}: {
|
||
data: SystemSnapshot;
|
||
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",
|
||
)
|
||
.slice(0, 8)
|
||
.reverse()
|
||
.map((e) => (
|
||
<article
|
||
className={
|
||
e.sourceKind === "USER_MESSAGE" ? "human" : "persona"
|
||
}
|
||
key={e.eventId}
|
||
>
|
||
<span>{labels[e.sourceKind]}</span>
|
||
<p>{e.content}</p>
|
||
</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>
|
||
{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",
|
||
)
|
||
.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>
|
||
);
|
||
}
|
||
|
||
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);
|
||
useEffect(() => {
|
||
if (selected)
|
||
api.readDocument(selected).then((d) => {
|
||
setDoc(d);
|
||
setBody(d.body);
|
||
});
|
||
}, [selected]);
|
||
const list = useMemo(
|
||
() =>
|
||
data.documents.filter((d) =>
|
||
d.title.toLowerCase().includes(query.toLowerCase()),
|
||
),
|
||
[data.documents, query],
|
||
);
|
||
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);
|
||
refresh();
|
||
}
|
||
};
|
||
const del = async () => {
|
||
if (doc && confirm(`把“${doc.title}”移入回收站?`)) {
|
||
await api.deleteDocument(doc.path);
|
||
setSelected(null);
|
||
setDoc(null);
|
||
refresh();
|
||
}
|
||
};
|
||
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">
|
||
<aside>
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="搜索知识库"
|
||
/>
|
||
{list.length === 0 ? (
|
||
<p className="empty">暂无知识页</p>
|
||
) : (
|
||
list.map((d) => (
|
||
<button
|
||
className={selected === d.path ? "selected" : ""}
|
||
key={d.path}
|
||
onClick={() => setSelected(d.path)}
|
||
>
|
||
<strong>{d.title}</strong>
|
||
<small>
|
||
{new Date(d.updatedAt).toLocaleDateString("zh-CN")}
|
||
</small>
|
||
</button>
|
||
))
|
||
)}
|
||
</aside>
|
||
<section>
|
||
{doc ? (
|
||
<>
|
||
<div className="editor-tools">
|
||
<strong>{doc.title}</strong>
|
||
<span />
|
||
<button onClick={() => api.exportDocument(doc.path)}>
|
||
下载
|
||
</button>
|
||
<button className="danger" onClick={del}>
|
||
删除
|
||
</button>
|
||
<button onClick={save}>保存</button>
|
||
</div>
|
||
<textarea
|
||
className="editor"
|
||
value={body}
|
||
onChange={(e) => setBody(e.target.value)}
|
||
/>
|
||
</>
|
||
) : (
|
||
<div className="welcome">
|
||
<Icon name="book" />
|
||
<h2>从第一条知识开始</h2>
|
||
<p>新建页面,或导入一个外部文件夹。原文件不会被改写。</p>
|
||
<button onClick={() => setNewTitle("")}>新建知识页</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</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>
|
||
<a href="https://guanghu.chat" target="_blank" rel="noreferrer">
|
||
打开企业门户 ↗
|
||
</a>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
function Settings({
|
||
data,
|
||
refresh,
|
||
busy,
|
||
setBusy,
|
||
setError,
|
||
}: {
|
||
data: SystemSnapshot;
|
||
refresh: () => void;
|
||
busy: boolean;
|
||
setBusy: (v: boolean) => void;
|
||
setError: (v: string) => void;
|
||
}) {
|
||
const [bridgeName, setBridgeName] = 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>以来源明确的本地表达桥接入;不能取得人格或执行主控权。</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>
|
||
{data.externalAiBridges.map((b) => (
|
||
<article key={b.bridgeId}>
|
||
<div>
|
||
<h2>{b.displayName}</h2>
|
||
<p>
|
||
{b.bridgeId} · {b.inboxPath}
|
||
</p>
|
||
</div>
|
||
<span className="status">{b.state}</span>
|
||
</article>
|
||
))}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|