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

629 lines
18 KiB
TypeScript
Raw Normal View History

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>
);
}