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

629 lines
18 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, 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>
);
}