ReMe/reme_studio/app/workspace.tsx
jinliyl d529ec5256
feat(studio): add bilingual browser demo to documentation site (#581)
* feat(studio): add bilingual browser demo to documentation site

* fix(studio): preserve literal wikilinks during Markdown parsing

* fix(studio): preserve Markdown references and escaped wikilinks
2026-10-03 22:34:11 +08:00

770 lines
24 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import WorkspaceMarkdown from "./files-workspace/WorkspaceMarkdown";
import { IS_DEMO } from "./studio-mode";
import { openWorkspaceFile } from "./open-file";
import { SparkMenuExpandLine, SparkMenuFoldLine } from "@agentscope-ai/icons";
import {
BookOpenText,
Bot,
Check,
CircleAlert,
FileText,
LoaderCircle,
Monitor,
Moon,
Network,
Send,
Settings,
Sparkles,
Sun,
SunMoon,
X,
} from "lucide-react";
import { readWorkspaceFile, streamChat } from "./api";
import { chatStreamError, formatStreamPayloads } from "./chat-stream";
import FilesNavigator from "./files-workspace/FilesNavigator";
import MemoryGraphView from "./files-workspace/MemoryGraphView";
import { clampNavigatorWidth } from "./files-workspace/panel-resize";
import { useI18n, useLanguageStore } from "./i18n";
import { useWorkspaceStore } from "./store";
import { type ThemePreference, useThemeStore } from "./theme";
import type {
ChatBlock,
ChatMessage,
DetailBlock,
WorkspaceTab,
} from "./types";
import {
appendWorkspaceFileReference,
WORKSPACE_FILE_DRAG_TYPE,
} from "./workspace-drag";
const SettingsCenter = IS_DEMO
? null
: dynamic(() => import("./settings-center"), { ssr: false });
const DemoBar = IS_DEMO
? dynamic(() => import("../demo/DemoBar"), { ssr: false })
: null;
import { hasUnsavedChanges, unsavedTabsClosedBy } from "./tab-close";
import packageJson from "../package.json";
import { scenarios } from "../demo/scenarios";
const TabbedEditor = dynamic(() => import("./files-workspace/TabbedEditor"), {
ssr: false,
});
function GitHubIcon({ size = 18 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 .7a11.5 11.5 0 0 0-3.64 22.4c.58.1.79-.25.79-.56v-2.23c-3.23.7-3.91-1.37-3.91-1.37-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.71.08-.71 1.16.08 1.78 1.2 1.78 1.2 1.04 1.77 2.72 1.26 3.38.96.1-.75.4-1.26.74-1.55-2.58-.29-5.29-1.29-5.29-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.47.11-3.05 0 0 .97-.31 3.16 1.18A11 11 0 0 1 12 6.11c.98 0 1.96.13 2.88.39 2.2-1.49 3.16-1.18 3.16-1.18.63 1.58.23 2.76.11 3.05.74.81 1.19 1.83 1.19 3.09 0 4.4-2.72 5.38-5.3 5.67.42.36.79 1.07.79 2.16v3.25c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .7Z" />
</svg>
);
}
function ThemeMenu() {
const { t } = useI18n();
const menuRef = useRef<HTMLDetailsElement>(null);
const preference = useThemeStore((state) => state.preference);
const setPreference = useThemeStore((state) => state.setPreference);
useEffect(() => {
const close = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node))
menuRef.current?.removeAttribute("open");
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
const options: Array<[ThemePreference, string, React.ReactNode]> = [
["light", t("lightTheme"), <Sun key="light" size={15} />],
["dark", t("darkTheme"), <Moon key="dark" size={15} />],
["system", t("systemTheme"), <Monitor key="system" size={15} />],
];
return (
<details className="theme-menu" ref={menuRef}>
<summary aria-label={t("appearance")} title={t("appearance")}>
<SunMoon size={18} />
</summary>
<div role="menu">
{options.map(([value, label, icon]) => (
<button
key={value}
role="menuitemradio"
aria-checked={preference === value}
className={preference === value ? "active" : ""}
onClick={() => {
setPreference(value);
menuRef.current?.removeAttribute("open");
}}
>
{icon}
<span>{label}</span>
{preference === value && <Check size={14} />}
</button>
))}
</div>
</details>
);
}
function Tabs() {
const { t } = useI18n();
const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs } =
useWorkspaceStore();
const [contextMenu, setContextMenu] = useState<{
tabId: string;
left: number;
top: number;
}>();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!contextMenu) return;
const close = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node))
setContextMenu(undefined);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setContextMenu(undefined);
};
const closeOnViewportChange = () => setContextMenu(undefined);
document.addEventListener("pointerdown", close);
document.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", closeOnViewportChange);
window.addEventListener("scroll", closeOnViewportChange, true);
return () => {
document.removeEventListener("pointerdown", close);
document.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", closeOnViewportChange);
window.removeEventListener("scroll", closeOnViewportChange, true);
};
}, [contextMenu]);
const confirmDiscard = (tabId: string, closeOthers: boolean) => {
const unsaved = unsavedTabsClosedBy(tabs, tabId, closeOthers);
return (
!unsaved.length ||
window.confirm(
t("discardUnsavedConfirm", { count: String(unsaved.length) }),
)
);
};
return (
<>
<div className="tabs" role="tablist">
{tabs.map((tab) => {
const dirty = hasUnsavedChanges(tab);
return (
<button
key={tab.id}
className={`tab ${tab.id === activeTabId ? "active" : ""}`}
onClick={() => setActiveTab(tab.id)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({
tabId: tab.id,
left: Math.max(
8,
Math.min(event.clientX, window.innerWidth - 176),
),
top: Math.max(
8,
Math.min(event.clientY, window.innerHeight - 82),
),
});
}}
role="tab"
>
{tab.type === "agent" ? (
<Bot size={14} />
) : tab.type === "graph" ? (
<Network size={14} />
) : (
<FileText size={14} />
)}
<span>
{tab.type === "markdown"
? tab.path.split("/").slice(-2).join("/")
: tab.type === "graph"
? `${tab.root} · ${t("memoryGraphShort")}`
: tab.title || t("newConversation")}
</span>
{dirty ? (
<i className="dirty" />
) : (
<X
size={13}
onClick={(event) => {
event.stopPropagation();
closeTab(tab.id);
}}
/>
)}
</button>
);
})}
</div>
{contextMenu && (
<div
className="tab-context-menu"
ref={menuRef}
role="menu"
style={{ left: contextMenu.left, top: contextMenu.top }}
>
<button
role="menuitem"
onClick={() => {
if (!confirmDiscard(contextMenu.tabId, false)) return;
closeTab(contextMenu.tabId, true);
setContextMenu(undefined);
}}
>
{t("closeCurrentTab")}
</button>
<button
role="menuitem"
disabled={tabs.length <= 1}
onClick={() => {
if (!confirmDiscard(contextMenu.tabId, true)) return;
closeOtherTabs(contextMenu.tabId, true);
setContextMenu(undefined);
}}
>
{t("closeOtherTabs")}
</button>
</div>
)}
</>
);
}
function MarkdownView({ content }: { content: string }) {
return (
<article className="markdown">
<WorkspaceMarkdown content={content} />
</article>
);
}
function ChunkStatus({
active,
error = false,
}: {
active: boolean;
error?: boolean;
}) {
if (error) return <CircleAlert size={13} />;
return active ? (
<LoaderCircle className="spin" size={13} />
) : (
<Check size={13} />
);
}
function detailLabel(
block: DetailBlock,
t: ReturnType<typeof useI18n>["t"],
): string {
if (block.type === "think") return t("thinking");
if (block.type === "data")
return block.mediaType
? `${t("dataChunk")} · ${block.mediaType}`
: t("dataChunk");
if (block.type === "approval") return t("approvalChunk");
if (block.type === "usage") return t("usageChunk");
return t("unknownChunk", { type: block.sourceType });
}
function ChatBlockView({
block,
onToggle,
}: {
block: ChatBlock;
onToggle: (blockId: string, expanded: boolean) => void;
}) {
const { t } = useI18n();
if (block.type === "content") return <MarkdownView content={block.text} />;
if (block.type === "error")
return (
<div className="chunk-error">
<CircleAlert size={14} />
{block.text}
</div>
);
if (block.type === "usage") return null;
const active =
block.status === "streaming" ||
block.status === "calling" ||
block.status === "running";
const failed = block.status === "error";
if (block.type === "tool") {
const call = formatStreamPayloads(block.callPayloads);
const result = formatStreamPayloads(block.resultPayloads);
return (
<details
className={`stream-block tool ${failed ? "failed" : ""}`}
open={block.expanded}
onToggle={(event) => {
if (event.currentTarget.open !== block.expanded)
onToggle(block.id, event.currentTarget.open);
}}
>
<summary>
<ChunkStatus active={active} error={failed} />
<span>{block.name}</span>
<small>{active ? t("streaming") : t("completed")}</small>
</summary>
<div className="stream-block-body">
{call && (
<section>
<strong>{t("toolCall")}</strong>
<pre>{call}</pre>
</section>
)}
{result && (
<section>
<strong>{t("toolResult")}</strong>
<pre className="tool-result-scroll">{result}</pre>
</section>
)}
</div>
</details>
);
}
const detail = formatStreamPayloads(block.payloads);
return (
<details
className={`stream-block ${block.type} ${failed ? "failed" : ""}`}
open={block.expanded}
onToggle={(event) => {
if (event.currentTarget.open !== block.expanded)
onToggle(block.id, event.currentTarget.open);
}}
>
<summary>
<ChunkStatus active={active} error={failed} />
<span>{detailLabel(block, t)}</span>
<small>{active ? t("streaming") : t("completed")}</small>
</summary>
{detail && (
<div className="stream-block-body">
<pre>{detail}</pre>
</div>
)}
</details>
);
}
function Chat({ tab }: { tab: Extract<WorkspaceTab, { type: "agent" }> }) {
const { language, t } = useI18n();
const [input, setInput] = useState("");
const [fileDragOver, setFileDragOver] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
const controller = useRef<AbortController | null>(null);
const { addChatTurn, applyChatChunk, toggleChatBlock, finishChat } =
useWorkspaceStore();
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [tab.messages]);
useEffect(() => () => controller.current?.abort(), []);
const suggestions = (
<div className="suggestions">
{(IS_DEMO
? scenarios[language].map((scenario) => scenario.question)
: [t("promptRecent"), t("promptTasks"), t("promptIdeas")]
).map((question) => (
<button key={question} onClick={() => setInput(question)}>
{question}
</button>
))}
</div>
);
const send = async () => {
const query = input.trim();
if (!query || tab.streaming) return;
setInput("");
const user: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content: query,
};
const assistant: ChatMessage = {
id: crypto.randomUUID(),
role: "assistant",
content: "",
blocks: [],
};
addChatTurn(tab.id, user, assistant);
const requestController = new AbortController();
controller.current = requestController;
const error = await chatStreamError(
() =>
streamChat(query, tab.sessionId, requestController.signal, (chunk) =>
applyChatChunk(tab.id, assistant.id, chunk),
),
requestController.signal,
t("chatFailed"),
);
finishChat(tab.id, assistant.id, error);
if (controller.current === requestController) controller.current = null;
};
return (
<div className="chat">
<div className="messages">
{!tab.messages.length && (
<div className="chat-empty">
<div className="agent-logo" aria-hidden="true" />
<h1>{t("chatTitle")}</h1>
<p>{t(IS_DEMO ? "demoChatDescription" : "chatDescription")}</p>
{suggestions}
</div>
)}
{tab.messages.map((message) => (
<div className={`message ${message.role}`} key={message.id}>
<div className="avatar">
{message.role === "user" ? (
t("you")
) : (
<span
className="assistant-logo"
aria-label="ReMe"
title="ReMe"
/>
)}
</div>
<div className="bubble">
{message.role === "user" && <p>{message.content}</p>}
{message.role === "assistant" &&
message.blocks?.map((block) => (
<ChatBlockView
key={block.id}
block={block}
onToggle={(blockId, expanded) =>
toggleChatBlock(tab.id, message.id, blockId, expanded)
}
/>
))}
{message.role === "assistant" &&
tab.streaming &&
!message.blocks?.some((block) => block.type !== "usage") && (
<LoaderCircle className="spin" size={16} />
)}
</div>
</div>
))}
{IS_DEMO && tab.messages.length > 0 && suggestions}
<div ref={endRef} />
</div>
<div
className={`composer ${fileDragOver ? "file-drag-over" : ""}`}
onDragOver={(event) => {
if (!event.dataTransfer.types.includes(WORKSPACE_FILE_DRAG_TYPE))
return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setFileDragOver(true);
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
setFileDragOver(false);
}}
onDrop={(event) => {
const path = event.dataTransfer.getData(WORKSPACE_FILE_DRAG_TYPE);
if (!path) return;
event.preventDefault();
setFileDragOver(false);
setInput((current) => appendWorkspaceFileReference(current, path));
}}
>
<div>
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={t("askWorkspace")}
rows={1}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
/>
<button
onClick={send}
disabled={!input.trim() || tab.streaming}
aria-label={t("send")}
>
<Send size={17} />
</button>
</div>
<span>{t(IS_DEMO ? "demoChatHint" : "composerHint")}</span>
</div>
</div>
);
}
function Workspace() {
const { language, setLanguage, t } = useI18n();
const hydrateLanguage = useLanguageStore((state) => state.hydrate);
const hydrateTheme = useThemeStore((state) => state.hydrate);
const [navOpen, setNavOpen] = useState(true);
const [navigatorWidth, setNavigatorWidth] = useState(260);
const [resizingNavigator, setResizingNavigator] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const { tabs, activeTabId, openAgent, hydrateMarkdown, failMarkdown } =
useWorkspaceStore();
const active = tabs.find((tab) => tab.id === activeTabId);
useEffect(() => {
let mounted = true;
void Promise.resolve(useWorkspaceStore.persist.rehydrate()).then(
async () => {
if (IS_DEMO && !useWorkspaceStore.getState().tabs.length && mounted) {
await openWorkspaceFile("START_HERE.md");
}
const restoredFiles = useWorkspaceStore
.getState()
.tabs.filter(
(tab): tab is Extract<WorkspaceTab, { type: "markdown" }> =>
tab.type === "markdown" && Boolean(tab.loading),
);
await Promise.all(
restoredFiles.map(async (tab) => {
try {
const file = await readWorkspaceFile(tab.path);
if (mounted)
hydrateMarkdown(tab.id, file.content, file.stat.mtime);
} catch (error) {
if (mounted)
failMarkdown(
tab.id,
error instanceof Error
? error.message
: "Failed to read file",
);
}
}),
);
},
);
return () => {
mounted = false;
};
}, [failMarkdown, hydrateMarkdown]);
useEffect(() => {
hydrateLanguage();
}, [hydrateLanguage]);
useEffect(() => {
hydrateTheme();
}, [hydrateTheme]);
useEffect(() => {
const saved = Number(localStorage.getItem("reme-navigator-width"));
if (!Number.isFinite(saved) || saved <= 0) return;
const frame = requestAnimationFrame(() =>
setNavigatorWidth(clampNavigatorWidth(saved, window.innerWidth)),
);
return () => cancelAnimationFrame(frame);
}, []);
const resizeNavigator = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const navigator = event.currentTarget.parentElement;
const surface = navigator?.parentElement;
if (!navigator || !surface) return;
setResizingNavigator(true);
const startX = event.clientX;
const initialWidth = navigator.getBoundingClientRect().width;
const containerWidth = surface.getBoundingClientRect().width;
const move = (nextEvent: PointerEvent) => {
setNavigatorWidth(
clampNavigatorWidth(
initialWidth + nextEvent.clientX - startX,
containerWidth,
),
);
};
const stop = (nextEvent: PointerEvent) => {
const finalWidth = clampNavigatorWidth(
initialWidth + nextEvent.clientX - startX,
containerWidth,
);
setNavigatorWidth(finalWidth);
localStorage.setItem("reme-navigator-width", String(finalWidth));
setResizingNavigator(false);
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
};
const resizeNavigatorWithKeyboard = (
event: React.KeyboardEvent<HTMLDivElement>,
) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const containerWidth =
event.currentTarget.parentElement?.parentElement?.getBoundingClientRect()
.width ?? window.innerWidth;
setNavigatorWidth((current) => {
const next = clampNavigatorWidth(
current + (event.key === "ArrowRight" ? 24 : -24),
containerWidth,
);
localStorage.setItem("reme-navigator-width", String(next));
return next;
});
};
return (
<main
className={`shell ${navOpen ? "" : "nav-closed"} ${
resizingNavigator ? "navigator-is-resizing" : ""
}`}
style={
{ "--navigator-width": `${navigatorWidth}px` } as React.CSSProperties
}
>
<header className="topbar">
<button
className={`menu ${navOpen ? "is-open" : ""}`}
onClick={() => setNavOpen(!navOpen)}
aria-label={t("toggleNavigator")}
aria-expanded={navOpen}
aria-controls="workspace-navigator"
>
<span className="menu-icons" aria-hidden="true">
<SparkMenuFoldLine size={20} className="menu-fold-icon" />
<SparkMenuExpandLine size={20} className="menu-expand-icon" />
</span>
</button>
<strong>
<span className="brand-icon" aria-hidden="true" />
<span className="brand-title">ReMe Studio</span>
<span className="app-version-divider" aria-hidden="true" />
<span className="app-version">v{packageJson.version}</span>
</strong>
<span>
{active?.type === "markdown"
? active.path
: active?.type === "graph"
? `${active.root} · ${t("memoryGraph")}`
: active?.title || t("workspace")}
</span>
<div className="topbar-actions">
<nav className="resource-links" aria-label={t("officialWebsite")}>
<a
href="https://reme.agentscope.io"
target="_blank"
rel="noreferrer"
>
<BookOpenText size={17} />
<span>{t("officialWebsite")}</span>
</a>
<i aria-hidden="true" />
<a
href="https://github.com/modelscope/ReMe"
target="_blank"
rel="noreferrer"
>
<GitHubIcon />
<span>{t("github")}</span>
</a>
</nav>
<div className="topbar-divider" aria-hidden="true" />
<div
className="language-switch"
aria-label={t("switchLanguage")}
role="group"
>
<button
className={language === "zh" ? "active" : ""}
onClick={() => setLanguage("zh")}
>
中文
</button>
<button
className={language === "en" ? "active" : ""}
onClick={() => setLanguage("en")}
>
EN
</button>
</div>
<ThemeMenu />
{SettingsCenter && (
<button
className="settings-trigger"
onClick={() => setSettingsOpen(true)}
aria-label={t("settings")}
title={t("settings")}
>
<Settings size={18} />
</button>
)}
</div>
</header>
{DemoBar && <DemoBar />}
<div className="surface">
<FilesNavigator
open={navOpen}
width={navigatorWidth}
resizing={resizingNavigator}
onResizeStart={resizeNavigator}
onResizeKeyDown={resizeNavigatorWithKeyboard}
/>
<section className="workbench">
<Tabs />
<div className="content">
{!active && (
<div className="welcome">
<h1 className="welcome-brand">
<span
className="welcome-wordmark"
role="img"
aria-label="ReMe"
/>
<span>Studio</span>
</h1>
<p>{t("welcomeDescription")}</p>
<button onClick={openAgent}>
<Sparkles size={16} />
{t("startChat")}
</button>
<small>{t("localFiles")}</small>
</div>
)}
{active?.type === "markdown" && (
<TabbedEditor key={active.id} tab={active} />
)}
{active?.type === "agent" && <Chat tab={active} />}
{active?.type === "graph" && (
<MemoryGraphView key={active.id} root={active.root} />
)}
</div>
</section>
</div>
{SettingsCenter && (
<SettingsCenter
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
)}
</main>
);
}
export function ReMeWorkspace() {
return <Workspace />;
}