feat(web): add the ReMe Studio frontend (#418)

* feat(web): add the ReMe workspace frontend

* fix(web): use public npm registry in lockfile

* fix(web): address workspace review feedback

* fix(web): protect drafts and report file limits

* fix(web): finish chat streams after tab switches

* feat(web): rename frontend to ReMe Studio
This commit is contained in:
jinliyl 2026-08-11 19:47:59 +08:00 committed by GitHub
parent 3924f89bb4
commit b8f48c8004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 19272 additions and 0 deletions

41
.github/workflows/npm-format.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: NPM Format
on:
push:
paths:
- "website/**"
- ".github/workflows/npm-format.yml"
pull_request:
paths:
- "website/**"
- ".github/workflows/npm-format.yml"
jobs:
website:
name: Website checks
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: website/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run format check
run: npm run format:check
- name: Run lint
run: npm run lint
- name: Run tests
run: npm test

4
.gitignore vendored
View file

@ -32,6 +32,10 @@ build/
dist/
*.egg-info/
# Website build integration source (not generated output)
!website/build/
!website/build/**
# Logs / temporary files
*.log
nohup.out

2
website/.env.example Normal file
View file

@ -0,0 +1,2 @@
NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:2333
NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS=md,txt

43
website/.gitignore vendored Normal file
View file

@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vite/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
/dist/
/.wrangler/
/outputs/
/work/
*.tsbuildinfo

View file

@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a730f00ef248191914cf8ca92e120cb",
"d1": null,
"r2": null
}

18
website/.prettierignore Normal file
View file

@ -0,0 +1,18 @@
# Build output
.next/
.vinext/
dist/
out/
# Dependencies
node_modules/
# Cache and local state
.cache/
.wrangler/
*.cache
# Misc
coverage/
.vscode/
.idea/

42
website/README.md Normal file
View file

@ -0,0 +1,42 @@
# ReMe Studio
Local web studio for browsing ReMe files, editing and previewing Markdown,
exploring memory graphs, and streaming conversations with the ReMe Agent.
## Development
Requirements: Node.js 22.13+ and a running ReMe HTTP service.
```bash
# From the repository root, start ReMe in one terminal.
reme start
# Start the web interface in another terminal.
cd website
npm install
npm run dev
```
Open <http://localhost:3000>. The frontend connects to
`http://127.0.0.1:2333` by default. Override it when needed:
```bash
NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:8000 npm run dev
```
The workspace hides dotfiles and dot-directories. It displays only Markdown and
text files by default. Configure the allowed extensions as a comma-separated
list in `.env.local`:
```bash
NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS=md,txt,mdx
```
Useful checks:
```bash
npm run lint
npx tsc --noEmit
npm run build
npm test
```

143
website/app/api.ts Normal file
View file

@ -0,0 +1,143 @@
import type {
AppConfig,
FileStat,
GraphSnapshot,
ReMeResponse,
StreamChunk,
} from "./types";
import { decodeSseEvent } from "./chat-stream";
import { translate, useLanguageStore, type TranslationKey } from "./i18n";
import {
WORKSPACE_FILE_LIMIT,
workspaceFileListing,
type WorkspaceFileListing,
} from "./workspace-files";
export const REME_API_URL = (
process.env.NEXT_PUBLIC_REME_API_URL || "http://127.0.0.1:2333"
).replace(/\/$/, "");
const message = (key: TranslationKey, status: number) =>
translate(useLanguageStore.getState().language, key, {
status: String(status),
});
async function parseResponse<T>(response: Response): Promise<ReMeResponse<T>> {
let payload: ReMeResponse<T>;
try {
payload = (await response.json()) as ReMeResponse<T>;
} catch {
throw new Error(message("invalidResponse", response.status));
}
if (!response.ok || !payload.success) {
const detail =
typeof payload.answer === "string"
? payload.answer
: message("requestFailed", response.status);
throw new Error(detail);
}
return payload;
}
export async function callReMe<T>(
action: string,
body: Record<string, unknown> = {},
): Promise<ReMeResponse<T>> {
const response = await fetch(`${REME_API_URL}/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return parseResponse<T>(response);
}
export async function getAppConfig(): Promise<AppConfig> {
return (await callReMe<AppConfig>("app_config")).answer;
}
export async function getReMeVersion(): Promise<string> {
return String((await callReMe<string>("version")).answer);
}
export async function getReMeStatus(): Promise<ReMeResponse<string>> {
return callReMe<string>("status");
}
export async function rebuildReMeIndex(): Promise<ReMeResponse<unknown>> {
return callReMe<unknown>("reindex");
}
export async function getGraphSnapshot(): Promise<GraphSnapshot> {
return (await callReMe<GraphSnapshot>("graph_snapshot")).answer;
}
export async function listWorkspaceFiles(
extensions: string[],
): Promise<WorkspaceFileListing> {
const response = await callReMe<string>("list", {
path: "",
recursive: true,
limit: WORKSPACE_FILE_LIMIT,
sort_by: "mtime",
extensions,
});
return workspaceFileListing(response.metadata.items);
}
export async function readWorkspaceFile(
path: string,
): Promise<{ content: string; stat: FileStat }> {
const response = await callReMe<string>("load", { path });
return {
content: String(response.answer ?? ""),
stat: response.metadata as unknown as FileStat,
};
}
export async function saveWorkspaceFile(
path: string,
content: string,
expectedMtime?: string,
): Promise<FileStat> {
const response = await callReMe<string>("save", {
path,
content,
expected_mtime: expectedMtime || null,
});
return response.metadata as unknown as FileStat;
}
export async function streamChat(
query: string,
sessionId: string | undefined,
signal: AbortSignal,
onChunk: (chunk: StreamChunk) => void,
): Promise<void> {
const response = await fetch(`${REME_API_URL}/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({ query, session_id: sessionId || null }),
signal,
});
if (!response.ok || !response.body)
throw new Error(message("agentUnavailable", response.status));
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const events = buffer.split(/\r?\n\r?\n/);
buffer = events.pop() || "";
for (const event of events) {
const chunk = decodeSseEvent(event);
if (chunk) onChunk(chunk);
}
if (done) break;
}
const finalChunk = decodeSseEvent(buffer);
if (finalChunk) onChunk(finalChunk);
}

269
website/app/chat-stream.ts Normal file
View file

@ -0,0 +1,269 @@
import type {
ChatBlock,
ChatMessage,
DetailBlock,
StreamChunk,
StreamPayload,
ToolBlock,
} from "./types";
const detailTypes = new Set(["think", "data", "approval", "usage"]);
export async function chatStreamError(
operation: () => Promise<void>,
signal: AbortSignal,
fallback: string,
): Promise<string | undefined> {
try {
await operation();
return undefined;
} catch (error) {
if (signal.aborted) return undefined;
return error instanceof Error ? error.message : fallback;
}
}
export function decodeSseEvent(raw: string): StreamChunk | null {
const data = raw
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n");
if (!data) return null;
if (data === "[DONE]") return { chunk_type: "done", chunk: "", done: true };
return JSON.parse(data) as StreamChunk;
}
const payloadText = (payload: StreamPayload): string =>
typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
const appendError = (blocks: ChatBlock[], text: string): ChatBlock[] => {
if (!text) return blocks;
const last = blocks.at(-1);
if (last?.type === "error") {
return [...blocks.slice(0, -1), { ...last, text: last.text + text }];
}
return [...blocks, { id: `error:${blocks.length}`, type: "error", text }];
};
export function finishChatMessage(
message: ChatMessage,
error?: string,
): ChatMessage {
let blocks = (message.blocks || []).map((block): ChatBlock => {
if (block.type === "content" || block.type === "error") return block;
return {
...block,
status: block.status === "error" ? "error" : "done",
expanded: false,
};
});
if (error) blocks = appendError(blocks, error);
return { ...message, blocks };
}
function applyContent(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
const text = payloadText(chunk.chunk);
if (!text) return blocks;
const id = chunk.block_id || "";
const index = id
? blocks.findIndex(
(block) => block.type === "content" && block.id === `content:${id}`,
)
: blocks.length - 1;
const current = blocks[index];
if (current?.type === "content" && (id || index === blocks.length - 1)) {
const next = [...blocks];
next[index] = { ...current, text: current.text + text };
return next;
}
return [
...blocks,
{ id: `content:${id || blocks.length}`, type: "content", text },
];
}
function detailId(blocks: ChatBlock[], chunk: StreamChunk): string {
if (chunk.block_id) return `${chunk.chunk_type}:${chunk.block_id}`;
if (chunk.chunk_type === "approval" && chunk.metadata?.review_id) {
return `approval:${String(chunk.metadata.review_id)}`;
}
if (chunk.chunk_type === "usage") {
const active = [...blocks]
.reverse()
.find((block) => block.type === "usage" && block.status === "streaming");
if (active) return active.id;
}
const last = blocks.at(-1);
if (
last &&
last.type !== "content" &&
last.type !== "tool" &&
last.type !== "error" &&
last.sourceType === chunk.chunk_type &&
last.status === "streaming"
)
return last.id;
return `${chunk.chunk_type}:${blocks.length}`;
}
function applyDetail(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
const id = detailId(blocks, chunk);
const index = blocks.findIndex((block) => block.id === id);
const payloads = payloadText(chunk.chunk) ? [chunk.chunk] : [];
const done =
chunk.chunk_type === "usage" &&
(chunk.input_tokens !== undefined || chunk.output_tokens !== undefined);
const type = detailTypes.has(chunk.chunk_type)
? (chunk.chunk_type as DetailBlock["type"])
: "unknown";
const current = blocks[index];
const nextBlock: DetailBlock =
current &&
current.type !== "content" &&
current.type !== "tool" &&
current.type !== "error"
? {
...current,
payloads: [...current.payloads, ...payloads],
status: done ? "done" : "streaming",
expanded: true,
mediaType: chunk.media_type || current.mediaType,
inputTokens: chunk.input_tokens ?? current.inputTokens,
outputTokens: chunk.output_tokens ?? current.outputTokens,
metadata: { ...current.metadata, ...chunk.metadata },
}
: {
id,
type,
sourceType: chunk.chunk_type,
payloads,
status: done ? "done" : "streaming",
expanded: true,
mediaType: chunk.media_type,
inputTokens: chunk.input_tokens,
outputTokens: chunk.output_tokens,
metadata: chunk.metadata,
};
if (index < 0) return [...blocks, nextBlock];
const next = [...blocks];
next[index] = nextBlock;
return next;
}
function applyTool(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
const last = blocks.at(-1);
const activeToolId =
last?.type === "tool" && last.status !== "done" && last.status !== "error"
? last.id.slice("tool:".length)
: undefined;
const toolId =
chunk.tool_call_id ||
chunk.block_id ||
activeToolId ||
`${chunk.chunk_type}:${blocks.length}`;
const id = `tool:${toolId}`;
const index = blocks.findIndex(
(block) => block.type === "tool" && block.id === id,
);
const current = index >= 0 ? (blocks[index] as ToolBlock) : undefined;
const hasPayload = payloadText(chunk.chunk).length > 0;
const result = chunk.chunk_type === "tool_result";
const state = String(chunk.metadata?.state || "").toLowerCase();
const failed = state.includes("error") || state.includes("fail");
const nextBlock: ToolBlock = {
id,
type: "tool",
name: chunk.tool_call_name || current?.name || "ReMe tool",
callPayloads: result
? current?.callPayloads || []
: [
...(current?.callPayloads || []),
...(hasPayload ? [chunk.chunk] : []),
],
resultPayloads: result
? [
...(current?.resultPayloads || []),
...(hasPayload ? [chunk.chunk] : []),
]
: current?.resultPayloads || [],
status: failed
? "error"
: result
? state
? "done"
: "running"
: "calling",
expanded: true,
mediaType: chunk.media_type || current?.mediaType,
metadata: { ...current?.metadata, ...chunk.metadata },
};
if (index < 0) return [...blocks, nextBlock];
const next = [...blocks];
next[index] = nextBlock;
return next;
}
export function applyStreamChunk(
message: ChatMessage,
chunk: StreamChunk,
): ChatMessage {
if (chunk.chunk_type === "reply_end") {
const answer =
typeof chunk.metadata?.answer === "string" ? chunk.metadata.answer : "";
const hasContent = (message.blocks || []).some(
(block) => block.type === "content" && block.text.length > 0,
);
const completed =
answer && !hasContent
? {
...message,
blocks: [
...(message.blocks || []),
{
id: `content:final:${message.id}`,
type: "content" as const,
text: answer,
},
],
}
: message;
return finishChatMessage(completed);
}
if (chunk.chunk_type === "done" || chunk.done) {
return finishChatMessage(message);
}
if (chunk.chunk_type === "reply_start") return message;
const blocks = message.blocks || [];
if (chunk.chunk_type === "content")
return { ...message, blocks: applyContent(blocks, chunk) };
if (chunk.chunk_type === "tool_call" || chunk.chunk_type === "tool_result") {
return { ...message, blocks: applyTool(blocks, chunk) };
}
if (chunk.chunk_type === "error")
return {
...message,
blocks: appendError(blocks, payloadText(chunk.chunk)),
};
return { ...message, blocks: applyDetail(blocks, chunk) };
}
export function toggleChatBlock(
message: ChatMessage,
blockId: string,
expanded: boolean,
): ChatMessage {
return {
...message,
blocks: (message.blocks || []).map((block) =>
block.id === blockId && block.type !== "content" && block.type !== "error"
? { ...block, expanded }
: block,
),
};
}
export function formatStreamPayloads(payloads: StreamPayload[]): string {
return payloads.map(payloadText).join("");
}

View file

@ -0,0 +1,90 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
userId: string;
displayName: string;
email: string;
fullName: string | null;
};
const USER_ID_HEADER = "oai-authenticated-user-id";
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const userId = requestHeaders.get(USER_ID_HEADER);
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!userId || !email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
userId,
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}

View file

@ -0,0 +1,101 @@
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { parseMarkdownFrontmatter } from "./markdown";
import styles from "./files-workspace.module.css";
export type PreviewType = "markdown" | "csv" | "text";
export function getPreviewType(filePath: string): PreviewType {
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
if (ext === "md" || ext === "mdx") return "markdown";
if (ext === "csv") return "csv";
return "text";
}
function parseCsv(raw: string): string[][] {
return raw
.trimEnd()
.split(/\r?\n/)
.map((line) => {
const cells: string[] = [];
let current = "";
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if (char === '"') {
if (quoted && line[index + 1] === '"') {
current += '"';
index += 1;
} else quoted = !quoted;
} else if (char === "," && !quoted) {
cells.push(current);
current = "";
} else current += char;
}
cells.push(current);
return cells;
});
}
function MarkdownPreview({ content }: { content: string }) {
const { body, entries } = useMemo(
() => parseMarkdownFrontmatter(content),
[content],
);
return (
<article className={styles.markdownWrap}>
{entries.length > 0 && (
<dl className={styles.frontmatter} aria-label="Front matter">
{entries.map(({ key, value }, index) => (
<div className={styles.frontmatterRow} key={`${key}:${index}`}>
<dt>{key}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
)}
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</article>
);
}
function CsvPreview({ content }: { content: string }) {
const rows = useMemo(() => parseCsv(content), [content]);
const header = rows[0] ?? [];
return (
<div className={styles.csvScroll}>
<table className={styles.csvTable}>
<thead>
<tr>
{header.slice(0, 50).map((cell, index) => (
<th key={`${index}:${cell}`}>{cell}</th>
))}
</tr>
</thead>
<tbody>
{rows.slice(1, 501).map((row, rowIndex) => (
<tr key={rowIndex}>
{row.slice(0, 50).map((cell, cellIndex) => (
<td key={cellIndex}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function FilePreview({
filePath,
content,
}: {
filePath: string;
content: string;
}) {
const type = getPreviewType(filePath);
if (type === "markdown") return <MarkdownPreview content={content} />;
if (type === "csv") return <CsvPreview content={content} />;
return <pre className={styles.textPreview}>{content}</pre>;
}

View file

@ -0,0 +1,356 @@
"use client";
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex -- ARIA separators are adjustable controls when they expose a value and keyboard handlers. */
import { useEffect, useMemo, useState } from "react";
import {
BookOpen,
CalendarDays,
ChevronDown,
ChevronRight,
CircleAlert,
File,
FileText,
Folder,
FolderOpen,
FolderTree,
LoaderCircle,
MessageSquarePlus,
Network,
} from "lucide-react";
import {
getAppConfig,
listWorkspaceFiles,
readWorkspaceFile,
REME_API_URL,
} from "../api";
import { useI18n } from "../i18n";
import { useWorkspaceStore } from "../store";
import type {
AppConfig,
MemoryGraphRoot,
TreeNode,
WorkspaceSource,
} from "../types";
import {
absoluteWorkspacePath,
WORKSPACE_FILE_DRAG_TYPE,
} from "../workspace-drag";
import {
buildTree,
filterPathsBySource,
parseWorkspaceExtensions,
sourceDirectory,
WORKSPACE_FILE_LIMIT,
} from "../workspace-files";
const extensions = parseWorkspaceExtensions(
process.env.NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS,
);
const AUTO_REFRESH_MS = 10_000;
const isEditable = (name: string) =>
extensions.has(name.split(".").pop()?.toLowerCase() || "");
const loadWorkspace = () =>
Promise.all([getAppConfig(), listWorkspaceFiles([...extensions])]);
const remeEndpoint = (() => {
try {
return new URL(REME_API_URL).host;
} catch {
return REME_API_URL;
}
})();
function DirectoryNode({
node,
workspaceDir,
source,
depth = 0,
}: {
node: TreeNode;
workspaceDir: string;
source: WorkspaceSource;
depth?: number;
}) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(depth === 0);
const {
tabs,
activeTabId,
openMarkdown,
openGraph,
hydrateMarkdown,
failMarkdown,
} = useWorkspaceStore();
const active = tabs.find((tab) => tab.id === activeTabId);
const selected = active?.type === "markdown" && active.path === node.path;
if (node.type === "directory") {
const graphRoot =
source === "digest" &&
depth === 0 &&
["wiki", "personal", "procedure"].includes(node.name)
? (node.name as MemoryGraphRoot)
: undefined;
return (
<>
<div
className={`tree-directory-row ${
active?.type === "graph" && active.root === graphRoot
? "graph-active"
: ""
}`}
>
<button
className="tree-row"
style={{ paddingInlineStart: 10 + depth * 15 }}
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
>
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
<span>{node.name}</span>
</button>
{graphRoot && (
<button
className="tree-graph-button"
onClick={() => openGraph(graphRoot)}
aria-label={`${t("memoryGraph")} · ${node.name}`}
title={`${t("memoryGraph")} · ${node.name}`}
>
<Network size={13} />
<span>{t("memoryGraphShort")}</span>
</button>
)}
</div>
{expanded &&
node.children.map((child) => (
<DirectoryNode
key={child.path}
node={child}
workspaceDir={workspaceDir}
source={source}
depth={depth + 1}
/>
))}
</>
);
}
const open = async () => {
if (!isEditable(node.name)) return;
const existing = tabs.some(
(tab) => tab.type === "markdown" && tab.path === node.path,
);
const id = openMarkdown(node.path);
if (existing) return;
try {
const file = await readWorkspaceFile(node.path);
hydrateMarkdown(id, file.content, file.stat.mtime);
} catch (error) {
failMarkdown(
id,
error instanceof Error ? error.message : t("fileReadFailed"),
);
}
};
const absolutePath = workspaceDir
? absoluteWorkspacePath(workspaceDir, node.path)
: "";
return (
<button
className={`tree-row file-row ${selected ? "selected" : ""}`}
disabled={!isEditable(node.name)}
draggable={Boolean(absolutePath) && isEditable(node.name)}
onDragStart={(event) => {
if (!absolutePath) return;
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(WORKSPACE_FILE_DRAG_TYPE, absolutePath);
event.dataTransfer.setData("text/plain", absolutePath);
}}
style={{ paddingInlineStart: 28 + depth * 15 }}
onClick={() => void open()}
title={node.path}
>
{isEditable(node.name) ? <FileText size={15} /> : <File size={15} />}
<span>{node.name}</span>
</button>
);
}
/** ReMe adapter of QwenPaw FilesNavigator. Profile/archive, project switching and Git are intentionally omitted. */
interface FilesNavigatorProps {
open: boolean;
width: number;
resizing: boolean;
onResizeStart: (event: React.PointerEvent<HTMLDivElement>) => void;
onResizeKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
}
export default function FilesNavigator({
open,
width,
resizing,
onResizeStart,
onResizeKeyDown,
}: FilesNavigatorProps) {
const { t } = useI18n();
const [config, setConfig] = useState<AppConfig>();
const [paths, setPaths] = useState<string[]>([]);
const [limited, setLimited] = useState(false);
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading",
);
const [source, setSource] = useState<WorkspaceSource>("workspace");
const openAgent = useWorkspaceStore((state) => state.openAgent);
useEffect(() => {
let mounted = true;
let loading = false;
const refresh = async () => {
if (loading || !mounted) return;
loading = true;
try {
const [nextConfig, listing] = await loadWorkspace();
if (!mounted) return;
setConfig(nextConfig);
setPaths(listing.paths);
setLimited(listing.limited);
setStatus("ready");
} catch {
if (mounted) setStatus("error");
} finally {
loading = false;
}
};
void refresh();
const timer = window.setInterval(() => {
if (!document.hidden) void refresh();
}, AUTO_REFRESH_MS);
const onVisibilityChange = () => {
if (!document.hidden) void refresh();
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
mounted = false;
window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, []);
const visiblePaths = useMemo(
() => (config ? filterPathsBySource(paths, source, config) : paths),
[config, paths, source],
);
const tree = useMemo(
() =>
buildTree(
visiblePaths,
extensions,
config ? sourceDirectory(source, config) : "",
),
[config, source, visiblePaths],
);
return (
<aside
id="workspace-navigator"
className={`navigator ${open ? "" : "navigator-closed"} ${
resizing ? "navigator-resizing" : ""
}`}
style={{ width: open ? width : 0 }}
aria-hidden={!open}
inert={!open}
>
<div
className="navigator-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={t("resizeNavigator")}
aria-valuemin={220}
aria-valuenow={Math.round(width)}
tabIndex={0}
onPointerDown={onResizeStart}
onKeyDown={onResizeKeyDown}
/>
<div className="navigator-content">
<div className="source-tabs" role="tablist">
<button
role="tab"
aria-selected={source === "workspace"}
className={source === "workspace" ? "active" : ""}
onClick={() => setSource("workspace")}
>
<FolderTree size={13} />
{t("workspaceTab")}
</button>
<button
role="tab"
aria-selected={source === "daily"}
className={source === "daily" ? "active" : ""}
onClick={() => setSource("daily")}
>
<CalendarDays size={13} />
{t("dailyTab")}
</button>
<button
role="tab"
aria-selected={source === "digest"}
className={source === "digest" ? "active" : ""}
onClick={() => setSource("digest")}
>
<BookOpen size={13} />
{t("knowledgeTab")}
</button>
<button
className="chat-tab"
role="tab"
aria-selected={false}
aria-label={t("newAgentChat")}
onClick={openAgent}
>
<MessageSquarePlus size={13} />
{t("chatTab")}
</button>
</div>
<div className="tree" role="tree" aria-busy={status === "loading"}>
{status === "loading" && !paths.length && (
<div className="side-state">
<LoaderCircle className="spin" size={15} />
{t("loadingWorkspace")}
</div>
)}
{status === "error" && (
<div className="side-state error">
<CircleAlert size={15} />
{t("connectionFailed")}
</div>
)}
{status === "ready" && !tree.length && (
<div className="side-state">{t("emptyWorkspace")}</div>
)}
{status === "ready" && limited && (
<div className="side-state warning" role="status">
<CircleAlert size={15} />
{t("workspaceFileLimit", {
limit: WORKSPACE_FILE_LIMIT.toLocaleString(),
})}
</div>
)}
{tree.map((node) => (
<DirectoryNode
key={node.path}
node={node}
workspaceDir={config?.workspace_dir || ""}
source={source}
/>
))}
</div>
<div className="workspace-path" title={REME_API_URL}>
<span
className={`status-dot ${status === "error" ? "offline" : ""}`}
/>
<span>
{status === "error"
? `${t("connectionFailed")} · ${remeEndpoint}`
: remeEndpoint}
</span>
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,501 @@
"use client";
import {
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
CircleAlert,
ExternalLink,
Link2,
LoaderCircle,
Maximize2,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { getGraphSnapshot, readWorkspaceFile } from "../api";
import { useI18n } from "../i18n";
import { useWorkspaceStore } from "../store";
import type { GraphSnapshot, MemoryGraphRoot } from "../types";
import {
edgePath,
GRAPH_HEIGHT,
GRAPH_WIDTH,
graphBelowRoot,
INNER_RING_RADIUS,
layoutGraph,
nodeLabel,
OUTER_RING_RADIUS,
reciprocalEdgeKeys,
shortNodeLabel,
type PositionedGraphNode,
} from "./memory-graph";
import styles from "./memory-graph.module.css";
const AUTO_REFRESH_MS = 10_000;
interface NodeOffset {
x: number;
y: number;
}
interface DragSession {
pointerId: number;
nodeId: string;
start: NodeOffset;
base: NodeOffset;
}
function pointerPosition(
event: ReactPointerEvent<SVGGElement>,
zoom: number,
): NodeOffset {
const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect();
if (!bounds?.width || !bounds.height)
return { x: event.clientX, y: event.clientY };
const viewX = ((event.clientX - bounds.left) / bounds.width) * GRAPH_WIDTH;
const viewY = ((event.clientY - bounds.top) / bounds.height) * GRAPH_HEIGHT;
return {
x: GRAPH_WIDTH / 2 + (viewX - GRAPH_WIDTH / 2) / zoom,
y: GRAPH_HEIGHT / 2 + (viewY - GRAPH_HEIGHT / 2) / zoom,
};
}
export default function MemoryGraphView({ root }: { root: MemoryGraphRoot }) {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<GraphSnapshot>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [selectedId, setSelectedId] = useState("");
const [hoveredId, setHoveredId] = useState("");
const [zoom, setZoom] = useState(1);
const [offsets, setOffsets] = useState<Record<string, NodeOffset>>({});
const [draggingId, setDraggingId] = useState("");
const dragSession = useRef<DragSession | undefined>(undefined);
const didDrag = useRef(false);
const tabs = useWorkspaceStore((state) => state.tabs);
const openMarkdown = useWorkspaceStore((state) => state.openMarkdown);
const hydrateMarkdown = useWorkspaceStore((state) => state.hydrateMarkdown);
const failMarkdown = useWorkspaceStore((state) => state.failMarkdown);
useEffect(() => {
let mounted = true;
let fetching = false;
const load = async () => {
if (!mounted || fetching) return;
fetching = true;
try {
const next = await getGraphSnapshot();
if (!mounted) return;
setSnapshot(next);
setError(false);
setSelectedId((current) =>
next.nodes.some((node) => node.id === current) ? current : "",
);
} catch {
if (mounted) setError(true);
} finally {
fetching = false;
if (mounted) setLoading(false);
}
};
void load();
const timer = window.setInterval(() => {
if (!document.hidden) void load();
}, AUTO_REFRESH_MS);
const onVisibilityChange = () => {
if (!document.hidden) void load();
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
mounted = false;
window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, []);
const graphSnapshot = useMemo(
() => (snapshot ? graphBelowRoot(snapshot, root) : undefined),
[root, snapshot],
);
const baseGraph = useMemo(
() => layoutGraph(graphSnapshot || { version: 1, nodes: [], edges: [] }),
[graphSnapshot],
);
const graph = useMemo(() => {
const nodes = baseGraph.nodes.map((node) => ({
...node,
x: node.x + (offsets[node.id]?.x || 0),
y: node.y + (offsets[node.id]?.y || 0),
}));
return { nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
}, [baseGraph, offsets]);
const selected = graphSnapshot?.nodes.find((node) => node.id === selectedId);
const activeId = hoveredId || selectedId;
const inbound =
graphSnapshot?.edges.filter((edge) => edge.target === selectedId) || [];
const outbound =
graphSnapshot?.edges.filter((edge) => edge.source === selectedId) || [];
const neighbors = useMemo(() => {
const result = new Set<string>();
graphSnapshot?.edges.forEach((edge) => {
if (edge.source === activeId) result.add(edge.target);
if (edge.target === activeId) result.add(edge.source);
});
return result;
}, [activeId, graphSnapshot]);
const reciprocal = useMemo(
() => reciprocalEdgeKeys(graphSnapshot?.edges || []),
[graphSnapshot],
);
const labelIds = useMemo(() => {
const limit = Math.min(
10,
Math.max(5, Math.ceil(Math.sqrt(graph.nodes.length) * 1.5)),
);
return new Set(
[...graph.nodes]
.sort(
(left, right) =>
Number(right.virtual) - Number(left.virtual) ||
right.degree - left.degree ||
left.id.localeCompare(right.id),
)
.slice(0, limit)
.map((node) => node.id),
);
}, [graph.nodes]);
const openFile = async (node: PositionedGraphNode) => {
if (!node.indexed || node.virtual) return;
const existing = tabs.some(
(tab) => tab.type === "markdown" && tab.path === node.path,
);
const id = openMarkdown(node.path);
if (existing) return;
try {
const file = await readWorkspaceFile(node.path);
hydrateMarkdown(id, file.content, file.stat.mtime);
} catch (openError) {
failMarkdown(
id,
openError instanceof Error ? openError.message : t("fileReadFailed"),
);
}
};
const startDrag = (event: ReactPointerEvent<SVGGElement>, nodeId: string) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
dragSession.current = {
pointerId: event.pointerId,
nodeId,
start: pointerPosition(event, zoom),
base: offsets[nodeId] || { x: 0, y: 0 },
};
didDrag.current = false;
setDraggingId(nodeId);
event.currentTarget.setPointerCapture(event.pointerId);
};
const moveDrag = (event: ReactPointerEvent<SVGGElement>) => {
const drag = dragSession.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const pointer = pointerPosition(event, zoom);
const dx = pointer.x - drag.start.x;
const dy = pointer.y - drag.start.y;
if (Math.hypot(dx, dy) > 2) didDrag.current = true;
setOffsets((current) => ({
...current,
[drag.nodeId]: { x: drag.base.x + dx, y: drag.base.y + dy },
}));
};
const endDrag = (event: ReactPointerEvent<SVGGElement>) => {
if (dragSession.current?.pointerId !== event.pointerId) return;
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
dragSession.current = undefined;
setDraggingId("");
};
if (loading && !snapshot)
return (
<div className={styles.state}>
<LoaderCircle className="spin" size={18} />
{t("memoryGraphLoading")}
</div>
);
if (error && !snapshot)
return (
<div className={`${styles.state} ${styles.error}`}>
<CircleAlert size={18} />
{t("memoryGraphLoadFailed")}
</div>
);
return (
<section className={styles.view} aria-label={t("memoryGraph")}>
<header className={styles.toolbar}>
<div>
<strong>
{t("memoryGraph")} · {root}
</strong>
<span>
{t("memoryGraphCounts", {
nodes: String(graphSnapshot?.nodes.length || 0),
edges: String(graphSnapshot?.edges.length || 0),
})}
</span>
</div>
<div className={styles.actions}>
<button
onClick={() => setZoom((current) => Math.max(0.7, current - 0.15))}
aria-label={t("memoryGraphZoomOut")}
>
<ZoomOut size={15} />
</button>
<button
onClick={() => setZoom((current) => Math.min(1.6, current + 0.15))}
aria-label={t("memoryGraphZoomIn")}
>
<ZoomIn size={15} />
</button>
<button
onClick={() => {
setZoom(1);
setOffsets({});
}}
aria-label={t("memoryGraphFit")}
>
<Maximize2 size={15} />
</button>
</div>
</header>
{!graph.nodes.length ? (
<div className={styles.state}>
<Link2 size={18} />
{t("memoryGraphEmpty")}
</div>
) : (
<div
className={`${styles.content} ${selected ? styles.withDetails : ""}`}
>
<div className={styles.canvas}>
<svg viewBox={`0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`} role="img">
<title>{t("memoryGraph")}</title>
<defs>
<marker
id={`graph-arrow-${root}`}
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto"
>
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
<g
transform={`translate(${GRAPH_WIDTH / 2} ${
GRAPH_HEIGHT / 2
}) scale(${zoom}) translate(${-GRAPH_WIDTH / 2} ${
-GRAPH_HEIGHT / 2
})`}
>
<g className={styles.orbits} aria-hidden="true">
<circle
cx={GRAPH_WIDTH / 2}
cy={GRAPH_HEIGHT / 2}
r={INNER_RING_RADIUS}
/>
{graph.nodes.some((node) => node.layer === 2) && (
<circle
cx={GRAPH_WIDTH / 2}
cy={GRAPH_HEIGHT / 2}
r={OUTER_RING_RADIUS}
/>
)}
</g>
<g className={styles.edges}>
{graphSnapshot?.edges.map((edge) => {
const related =
edge.source === activeId || edge.target === activeId;
return (
<path
key={`${edge.source}:${edge.target}:${
edge.target_anchor || ""
}`}
d={edgePath(edge, graph.byId, reciprocal)}
className={`${related ? styles.edgeActive : ""} ${
activeId && !related ? styles.muted : ""
}`}
markerEnd={`url(#graph-arrow-${root})`}
>
<title>
{edge.source} {edge.target}
{edge.target_anchor ? `#${edge.target_anchor}` : ""}
</title>
</path>
);
})}
</g>
<g>
{graph.nodes.map((node) => {
const active = node.id === activeId;
const related = neighbors.has(node.id);
return (
<g
key={node.id}
className={`${styles.node} ${
node.virtual ? styles.root : ""
} ${node.degree >= 5 ? styles.hub : ""} ${
active ? styles.active : ""
} ${related ? styles.related : ""} ${
activeId && !active && !related ? styles.muted : ""
} ${draggingId === node.id ? styles.dragging : ""}`}
style={{
transform: `translate(${node.x}px, ${node.y}px)`,
}}
role="button"
tabIndex={0}
aria-label={nodeLabel(node)}
onClick={(event) => {
event.stopPropagation();
if (didDrag.current) {
didDrag.current = false;
return;
}
setSelectedId(node.id);
}}
onDoubleClick={() => void openFile(node)}
onPointerDown={(event) => startDrag(event, node.id)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId("")}
onFocus={() => setHoveredId(node.id)}
onBlur={() => setHoveredId("")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedId(node.id);
}
}}
>
<circle className={styles.halo} r={node.radius + 6} />
<circle className={styles.dot} r={node.radius} />
<title>{node.path}</title>
</g>
);
})}
</g>
<g className={styles.labels}>
{graph.nodes
.filter(
(node) => labelIds.has(node.id) || node.id === activeId,
)
.map((node) => (
<text
key={node.id}
className={
node.id === activeId ? styles.activeLabel : ""
}
x={node.x}
y={node.y + node.radius + 20}
textAnchor="middle"
>
{shortNodeLabel(node)}
</text>
))}
</g>
</g>
</svg>
<div className={styles.legend}>
<span>
<i />
{t("memoryGraphIndexed")}
</span>
<span>
<b></b>
{t("memoryGraphDirection")}
</span>
</div>
</div>
{selected && (
<aside className={styles.details}>
<span className={styles.status}>
{selected.virtual ? root : t("memoryGraphIndexed")}
</span>
<h2>{nodeLabel(selected)}</h2>
<code>{selected.path}</code>
{selected.description && <p>{selected.description}</p>}
{selected.indexed && !selected.virtual && (
<button
className={styles.openFile}
onClick={() =>
void openFile(
graph.byId.get(selected.id) as PositionedGraphNode,
)
}
>
<span>{t("memoryGraphOpenFile")}</span>
<ExternalLink size={14} />
</button>
)}
<div className={styles.links}>
<strong>
{t("memoryGraphOutbound", { count: String(outbound.length) })}
</strong>
{outbound.map((edge) => (
<button
key={`${edge.target}:${edge.target_anchor || ""}`}
onClick={() => setSelectedId(edge.target)}
>
<span>
{nodeLabel(
graph.byId.get(edge.target) || {
...selected,
name: "",
path: edge.target,
},
)}
</span>
<small>
{edge.target_anchor ? `#${edge.target_anchor}` : "→"}
</small>
</button>
))}
</div>
<div className={styles.links}>
<strong>
{t("memoryGraphInbound", { count: String(inbound.length) })}
</strong>
{inbound.map((edge) => (
<button
key={`${edge.source}:${edge.target_anchor || ""}`}
onClick={() => setSelectedId(edge.source)}
>
<span>
{nodeLabel(
graph.byId.get(edge.source) || {
...selected,
name: "",
path: edge.source,
},
)}
</span>
<small></small>
</button>
))}
</div>
</aside>
)}
</div>
)}
</section>
);
}

View file

@ -0,0 +1,158 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Editor from "@monaco-editor/react";
import { Check, Code2, Download, Eye, LoaderCircle, Save } from "lucide-react";
import { saveWorkspaceFile } from "../api";
import { useI18n } from "../i18n";
import "../monaco-setup";
import { useWorkspaceStore } from "../store";
import { useThemeStore } from "../theme";
import type { WorkspaceTab } from "../types";
import FilePreview from "./FilePreview";
import { getLanguage } from "./get-language";
import styles from "./files-workspace.module.css";
type FileTab = Extract<WorkspaceTab, { type: "markdown" }>;
/** Minimal ReMe port of QwenPaw's TabbedEditor: preview-first, Monaco edit, download and Cmd/Ctrl+S. */
export default function TabbedEditor({ tab }: { tab: FileTab }) {
const { t } = useI18n();
const [preview, setPreview] = useState(true);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const update = useWorkspaceStore((state) => state.updateMarkdown);
const markSaved = useWorkspaceStore((state) => state.markSaved);
const dirty = tab.content !== tab.savedContent;
const theme = useThemeStore((state) => state.resolved);
const save = useCallback(async () => {
if (!dirty || saving) return;
const submittedContent = tab.content;
setSaveError("");
setSaving(true);
try {
const stat = await saveWorkspaceFile(
tab.path,
submittedContent,
tab.mtime,
);
markSaved(tab.id, submittedContent, stat.mtime);
} catch (error) {
setSaveError(
t("saveFailed", {
error: error instanceof Error ? error.message : t("unknownError"),
}),
);
} finally {
setSaving(false);
}
}, [dirty, markSaved, saving, t, tab]);
useEffect(() => {
const shortcut = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
void save();
}
};
window.addEventListener("keydown", shortcut);
return () => window.removeEventListener("keydown", shortcut);
}, [save]);
const download = () => {
const url = URL.createObjectURL(
new Blob([tab.content], { type: "text/plain;charset=utf-8" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = tab.title;
anchor.click();
URL.revokeObjectURL(url);
};
if (tab.loading)
return (
<div className="center">
<LoaderCircle className="spin" />
{t("openingFile", { path: tab.path })}
</div>
);
if (tab.error) return <div className="center error">{tab.error}</div>;
return (
<div className={styles.wrap}>
<div className={styles.toolbar}>
<span className={styles.fileName}>{tab.path}</span>
{saveError && (
<span className={styles.saveError} role="alert" title={saveError}>
{saveError}
</span>
)}
<div className={styles.documentActions}>
<div className={styles.modeSwitch}>
<button
className={preview ? styles.modeActive : ""}
onClick={() => setPreview(true)}
>
<Eye size={12} />
{t("preview")}
</button>
<button
className={!preview ? styles.modeActive : ""}
onClick={() => setPreview(false)}
>
<Code2 size={12} />
{t("edit")}
</button>
</div>
<button
className={styles.iconBtn}
onClick={download}
aria-label={t("download")}
title={t("download")}
>
<Download size={13} />
</button>
{!preview && (
<button
className={styles.iconBtn}
onClick={() => void save()}
disabled={!dirty || saving}
aria-label={t("save")}
title={t("save")}
>
{saving ? (
<LoaderCircle className="spin" size={13} />
) : dirty ? (
<Save size={13} />
) : (
<Check size={13} />
)}
</button>
)}
</div>
</div>
<div className={styles.editor}>
{preview ? (
<FilePreview filePath={tab.path} content={tab.content} />
) : (
<Editor
path={tab.path}
language={getLanguage(tab.path)}
value={tab.content}
theme={theme === "dark" ? "vs-dark" : "vs"}
onChange={(value) => update(tab.id, value ?? "")}
options={{
minimap: { enabled: false },
fontSize: 13,
wordWrap: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,193 @@
.wrap {
height: 100%;
display: flex;
flex-direction: column;
background: var(--surface);
overflow: hidden;
}
.toolbar {
min-height: 39px;
padding: 4px 10px;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid var(--line);
}
.fileName {
min-width: 0;
flex: 1;
overflow: hidden;
color: #99918a;
font: 11px var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.saveError {
max-width: min(45%, 420px);
overflow: hidden;
color: #b42318;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.documentActions {
display: flex;
align-items: center;
gap: 5px;
}
.modeSwitch {
height: 30px;
padding: 2px;
display: flex;
gap: 2px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--panel);
}
.modeSwitch button {
height: 24px;
padding: 0 8px;
display: flex;
align-items: center;
gap: 5px;
border: 0;
border-radius: 5px;
color: var(--muted);
background: transparent;
font-size: 11px;
cursor: pointer;
}
.modeSwitch .modeActive {
color: #8e3b12;
background: var(--soft);
}
.iconBtn {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--muted);
background: var(--surface);
cursor: pointer;
}
.iconBtn:hover {
color: var(--accent);
border-color: #f2c7ad;
}
.iconBtn:disabled {
opacity: 0.42;
cursor: default;
}
.editor {
min-height: 0;
flex: 1;
overflow: hidden;
}
.editor :global(.monaco-editor) {
height: 100%;
}
.markdownWrap {
max-width: 920px;
height: 100%;
margin: 0 auto;
padding: 36px 42px 80px;
overflow: auto;
line-height: 1.75;
}
.markdownWrap > :first-child {
margin-top: 0;
}
.markdownWrap h1,
.markdownWrap h2,
.markdownWrap h3 {
line-height: 1.3;
}
.markdownWrap h1 {
font-size: 28px;
}
.markdownWrap h2 {
margin-top: 1.6em;
padding-bottom: 0.35em;
border-bottom: 1px solid var(--line);
font-size: 21px;
}
.markdownWrap a {
color: var(--accent);
}
.markdownWrap code {
padding: 2px 5px;
border-radius: 5px;
background: var(--code-bg);
font: 12px var(--mono);
}
.markdownWrap pre {
padding: 16px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel);
}
.markdownWrap pre code {
padding: 0;
background: transparent;
}
.frontmatter {
margin: 0 0 28px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--subtle);
}
.frontmatterRow {
display: grid;
grid-template-columns: minmax(88px, max-content) minmax(0, 1fr);
gap: 16px;
}
.frontmatterRow + .frontmatterRow {
margin-top: 8px;
}
.frontmatterRow dt {
color: var(--muted);
font: 600 12px var(--mono);
}
.frontmatterRow dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.textPreview {
height: 100%;
margin: 0;
padding: 28px 32px;
overflow: auto;
font: 13px/1.7 var(--mono);
white-space: pre-wrap;
}
.csvScroll {
height: 100%;
overflow: auto;
}
.csvTable {
width: max-content;
min-width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.csvTable th,
.csvTable td {
max-width: 280px;
padding: 6px 10px;
overflow: hidden;
border: 1px solid var(--line);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.csvTable th {
position: sticky;
top: 0;
background: var(--panel);
}

View file

@ -0,0 +1,31 @@
/** Extension → Monaco language id mapping, kept aligned with QwenPaw's Coding editor. */
export function getLanguage(path: string): string {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
py: "python",
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
json: "json",
yaml: "yaml",
yml: "yaml",
md: "markdown",
mdx: "markdown",
sh: "shell",
bash: "shell",
html: "html",
css: "css",
less: "less",
scss: "scss",
sql: "sql",
toml: "ini",
rs: "rust",
go: "go",
java: "java",
cpp: "cpp",
c: "c",
h: "c",
};
return map[ext] ?? "plaintext";
}

View file

@ -0,0 +1,27 @@
export interface FrontmatterEntry {
key: string;
value: string;
}
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
/** Split a leading YAML frontmatter block from the Markdown body. Copied from QwenPaw. */
export function parseMarkdownFrontmatter(content: string): {
body: string;
entries: FrontmatterEntry[];
} {
const match = FRONTMATTER_PATTERN.exec(content);
if (!match) return { body: content, entries: [] };
const entries = match[1]
.split(/\r?\n/)
.map((line) => {
const separator = line.indexOf(":");
if (separator <= 0 || /^\s/.test(line)) return null;
return {
key: line.slice(0, separator).trim(),
value: line.slice(separator + 1).trim(),
};
})
.filter((entry): entry is FrontmatterEntry => entry !== null);
return { body: content.slice(match[0].length), entries };
}

View file

@ -0,0 +1,384 @@
.view {
--graph-node: color-mix(in srgb, var(--muted) 70%, var(--surface));
--graph-edge: var(--muted);
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
color: var(--ink);
background: var(--bg);
}
.toolbar {
min-height: 58px;
padding: 9px 14px 9px 20px;
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--line);
}
.toolbar > div:first-child {
min-width: 0;
display: grid;
gap: 2px;
}
.toolbar strong {
font-size: 14px;
font-weight: 680;
}
.toolbar span {
color: var(--muted);
font-size: 11px;
}
.actions {
padding: 3px;
display: flex;
gap: 2px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel);
}
.actions button {
width: 28px;
height: 28px;
padding: 0;
display: grid;
place-items: center;
border: 0;
border-radius: 7px;
color: var(--muted);
background: transparent;
cursor: pointer;
}
.actions button:hover {
color: var(--accent);
background: var(--soft);
}
.content {
position: relative;
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.withDetails {
grid-template-columns: minmax(0, 1fr) clamp(220px, 22%, 290px);
}
.canvas {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: radial-gradient(
circle at 50% 45%,
color-mix(in srgb, var(--accent) 8%, transparent),
transparent 48%
),
radial-gradient(
circle,
color-mix(in srgb, var(--line) 58%, transparent) 0.7px,
transparent 0.9px
);
background-color: var(--surface);
background-size:
auto,
22px 22px;
}
.canvas svg {
width: 100%;
height: 100%;
display: block;
}
.canvas marker path {
fill: color-mix(in srgb, var(--graph-edge) 45%, transparent);
}
.orbits {
pointer-events: none;
}
.orbits circle {
fill: none;
stroke: color-mix(in srgb, var(--graph-edge) 28%, transparent);
stroke-dasharray: 3 9;
stroke-width: 1.1;
vector-effect: non-scaling-stroke;
}
.orbits circle:last-child {
stroke-dasharray: 2 10;
opacity: 0.72;
}
.edges path {
fill: none;
stroke: color-mix(in srgb, var(--graph-edge) 26%, transparent);
stroke-linecap: round;
stroke-width: 0.9;
transition:
opacity 140ms,
stroke 140ms,
stroke-width 140ms;
}
.edges .edgeActive {
stroke: color-mix(in srgb, var(--accent) 72%, var(--ink));
stroke-width: 2.1;
filter: drop-shadow(
0 1px 2px color-mix(in srgb, var(--accent) 30%, transparent)
);
}
.node {
cursor: pointer;
outline: none;
touch-action: none;
transition:
opacity 140ms,
transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.node circle {
transition:
fill 140ms,
filter 180ms,
stroke 140ms,
transform 140ms;
}
.halo {
fill: transparent;
opacity: 0;
stroke: color-mix(in srgb, var(--accent) 44%, transparent);
stroke-width: 2;
}
.dot {
fill: var(--graph-node);
stroke: color-mix(in srgb, var(--ink) 55%, var(--muted));
stroke-width: 1;
}
.node:hover .dot,
.node:focus-visible .dot {
stroke: var(--accent);
transform: scale(1.4);
}
.hub .dot {
fill: color-mix(in srgb, var(--accent) 72%, var(--ink));
stroke: color-mix(in srgb, var(--accent) 68%, var(--ink));
}
.root .dot {
fill: var(--accent);
stroke: color-mix(in srgb, var(--accent) 65%, var(--ink));
stroke-width: 1.8;
filter: drop-shadow(
0 2px 5px color-mix(in srgb, var(--accent) 30%, transparent)
);
}
.root .halo {
opacity: 0.25;
}
.active .halo {
opacity: 1;
}
.active .dot {
fill: var(--accent);
stroke: color-mix(in srgb, var(--accent) 65%, var(--ink));
stroke-width: 1.8;
filter: drop-shadow(
0 3px 6px color-mix(in srgb, var(--accent) 38%, transparent)
);
transform: scale(1.32);
}
.related .halo {
opacity: 0.42;
}
.dragging {
cursor: grabbing;
transition: none;
}
.dragging .halo {
opacity: 1;
stroke-width: 3;
}
.muted {
opacity: 0.22;
}
.labels {
pointer-events: none;
}
.labels text {
fill: color-mix(in srgb, var(--ink) 72%, var(--muted));
font-size: 10.5px;
font-weight: 570;
paint-order: stroke;
stroke: var(--surface);
stroke-linejoin: round;
stroke-width: 3px;
}
.labels .activeLabel {
fill: #fff;
stroke: var(--accent);
stroke-width: 7px;
font-size: 11px;
font-weight: 680;
}
.legend {
position: absolute;
left: 14px;
bottom: 12px;
padding: 7px 10px;
display: flex;
gap: 13px;
border: 1px solid color-mix(in srgb, var(--line) 75%, transparent);
border-radius: 9px;
color: var(--muted);
background: color-mix(in srgb, var(--panel) 88%, transparent);
font-size: 10px;
backdrop-filter: blur(10px);
}
.legend span {
display: flex;
align-items: center;
gap: 5px;
}
.legend i {
width: 9px;
height: 9px;
border: 1px solid var(--muted);
border-radius: 50%;
background: var(--graph-node);
}
.legend b {
color: var(--accent);
font-size: 13px;
font-weight: 500;
}
.details {
min-width: 0;
padding: 20px 17px;
overflow: auto;
border-left: 1px solid var(--line);
background: var(--panel);
}
.details h2 {
margin: 10px 0 5px;
overflow-wrap: anywhere;
font-size: 16px;
line-height: 1.3;
}
.details > code {
display: block;
overflow-wrap: anywhere;
color: var(--muted);
font: 10px var(--mono);
}
.details > p {
margin: 15px 0 0;
color: color-mix(in srgb, var(--ink) 76%, var(--muted));
font-size: 12px;
line-height: 1.6;
}
.status {
width: fit-content;
padding: 3px 7px;
border-radius: 999px;
color: color-mix(in srgb, var(--accent) 70%, var(--ink));
background: var(--soft);
font-size: 10px;
font-weight: 650;
}
.openFile {
width: 100%;
min-height: 34px;
margin-top: 16px;
padding: 7px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line));
border-radius: 9px;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--bg));
font-size: 11px;
font-weight: 650;
cursor: pointer;
}
.openFile:hover {
color: #fff;
border-color: var(--accent);
background: var(--accent);
}
.links {
margin-top: 20px;
display: grid;
gap: 5px;
}
.links > strong {
margin-bottom: 2px;
color: var(--muted);
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.links button {
min-width: 0;
padding: 7px 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
border: 1px solid transparent;
border-radius: 7px;
color: var(--ink);
background: color-mix(in srgb, var(--bg) 68%, transparent);
cursor: pointer;
text-align: left;
}
.links button:hover {
border-color: var(--line);
background: var(--bg);
}
.links button span {
min-width: 0;
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.links button small {
flex: none;
color: var(--accent);
font-size: 10px;
}
.state {
min-height: 220px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--muted);
background: var(--bg);
font-size: 12px;
}
.error {
color: var(--danger);
}
@media (max-width: 720px) {
.withDetails {
grid-template-columns: 1fr;
}
.details {
position: absolute;
right: 10px;
bottom: 10px;
width: min(260px, calc(100% - 20px));
max-height: 45%;
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(35, 25, 18, 0.18);
}
.legend {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.node,
.edges path {
transition: none;
}
}

View file

@ -0,0 +1,191 @@
import type {
GraphSnapshot,
GraphSnapshotEdge,
GraphSnapshotNode,
MemoryGraphRoot,
} from "../types";
export const GRAPH_WIDTH = 1080;
export const GRAPH_HEIGHT = 680;
export const INNER_RING_RADIUS = 164;
export const OUTER_RING_RADIUS = 270;
export interface PositionedGraphNode extends GraphSnapshotNode {
degree: number;
layer: 0 | 1 | 2;
x: number;
y: number;
radius: number;
}
export interface PositionedGraph {
nodes: PositionedGraphNode[];
byId: Map<string, PositionedGraphNode>;
}
export function nodeLabel(node: GraphSnapshotNode): string {
return (
node.name || node.path.split("/").pop()?.replace(/\.md$/i, "") || node.path
);
}
export function shortNodeLabel(node: GraphSnapshotNode): string {
const label = nodeLabel(node);
return label.length > 25 ? `${label.slice(0, 22)}` : label;
}
export function graphBelowRoot(
snapshot: GraphSnapshot,
root: MemoryGraphRoot,
): GraphSnapshot {
const rootId = `virtual:${root}`;
if (!snapshot.nodes.some((node) => node.id === rootId))
return { ...snapshot, nodes: [], edges: [] };
const outgoing = new Map<string, string[]>();
snapshot.edges.forEach((edge) =>
outgoing.set(edge.source, [
...(outgoing.get(edge.source) || []),
edge.target,
]),
);
const reachable = new Set([rootId]);
const queue = [rootId];
while (queue.length) {
const current = queue.shift() as string;
(outgoing.get(current) || []).forEach((target) => {
if (reachable.has(target)) return;
reachable.add(target);
queue.push(target);
});
}
return {
...snapshot,
nodes: snapshot.nodes.filter((node) => reachable.has(node.id)),
edges: snapshot.edges.filter(
(edge) => reachable.has(edge.source) && reachable.has(edge.target),
),
};
}
function degrees(snapshot: GraphSnapshot): Map<string, number> {
const result = new Map(snapshot.nodes.map((node) => [node.id, 0]));
snapshot.edges.forEach((edge) => {
result.set(edge.source, (result.get(edge.source) || 0) + 1);
result.set(edge.target, (result.get(edge.target) || 0) + 1);
});
return result;
}
export function layoutGraph(snapshot: GraphSnapshot): PositionedGraph {
const degree = degrees(snapshot);
const root = snapshot.nodes.find((node) => node.virtual) || snapshot.nodes[0];
const nodes: PositionedGraphNode[] = snapshot.nodes.map((node) => {
const nodeDegree = degree.get(node.id) || 0;
return {
...node,
degree: nodeDegree,
layer: node.id === root?.id ? 0 : 2,
x: GRAPH_WIDTH / 2,
y: GRAPH_HEIGHT / 2,
radius: node.virtual ? 11 : Math.min(9, 4 + Math.sqrt(nodeDegree) * 1.35),
};
});
const byId = new Map(nodes.map((node) => [node.id, node]));
if (!root) return { nodes, byId };
const outgoing = new Map<string, string[]>();
snapshot.edges.forEach((edge) => {
if (byId.has(edge.source) && byId.has(edge.target)) {
outgoing.set(edge.source, [
...(outgoing.get(edge.source) || []),
edge.target,
]);
}
});
const inner = [...new Set(outgoing.get(root.id) || [])]
.filter((id) => id !== root.id && byId.has(id))
.sort(
(left, right) =>
(degree.get(right) || 0) - (degree.get(left) || 0) ||
left.localeCompare(right),
);
const innerSet = new Set(inner);
const startAngle = -Math.PI / 2;
const innerAngles = new Map<string, number>();
inner.forEach((id, index) => {
const angle =
startAngle + (index / Math.max(1, inner.length)) * Math.PI * 2;
innerAngles.set(id, angle);
const node = byId.get(id) as PositionedGraphNode;
node.layer = 1;
node.x += Math.cos(angle) * INNER_RING_RADIUS;
node.y += Math.sin(angle) * INNER_RING_RADIUS;
});
const owner = new Map(inner.map((id) => [id, id]));
const branchQueue = [...inner];
while (branchQueue.length) {
const current = branchQueue.shift() as string;
(outgoing.get(current) || []).forEach((target) => {
if (target === root.id || owner.has(target)) return;
owner.set(target, owner.get(current) as string);
branchQueue.push(target);
});
}
const normalizeAngle = (angle?: number) =>
angle === undefined
? Number.POSITIVE_INFINITY
: (angle - startAngle + Math.PI * 2) % (Math.PI * 2);
const outer = nodes.filter(
(node) => node.id !== root.id && !innerSet.has(node.id),
);
outer.sort(
(left, right) =>
normalizeAngle(innerAngles.get(owner.get(left.id) || "")) -
normalizeAngle(innerAngles.get(owner.get(right.id) || "")) ||
right.degree - left.degree ||
left.id.localeCompare(right.id),
);
outer.forEach((node, index) => {
const angle =
startAngle + (index / Math.max(1, outer.length)) * Math.PI * 2;
node.x += Math.cos(angle) * OUTER_RING_RADIUS;
node.y += Math.sin(angle) * OUTER_RING_RADIUS;
});
return { nodes, byId };
}
export function reciprocalEdgeKeys(edges: GraphSnapshotEdge[]): Set<string> {
const keys = new Set(
edges.map((edge) => `${edge.source}\u0000${edge.target}`),
);
return new Set(
[...keys].filter((key) => {
const [source, target] = key.split("\u0000");
return keys.has(`${target}\u0000${source}`);
}),
);
}
export function edgePath(
edge: GraphSnapshotEdge,
byId: Map<string, PositionedGraphNode>,
reciprocal: Set<string>,
): string {
const source = byId.get(edge.source);
const target = byId.get(edge.target);
if (!source || !target) return "";
const dx = target.x - source.x;
const dy = target.y - source.y;
const distance = Math.max(1, Math.hypot(dx, dy));
const startX = source.x + (dx / distance) * (source.radius + 2);
const startY = source.y + (dy / distance) * (source.radius + 2);
const endX = target.x - (dx / distance) * (target.radius + 6);
const endY = target.y - (dy / distance) * (target.radius + 6);
if (!reciprocal.has(`${edge.source}\u0000${edge.target}`))
return `M ${startX} ${startY} L ${endX} ${endY}`;
const curve = edge.source.localeCompare(edge.target) < 0 ? 16 : -16;
const midX = (startX + endX) / 2 - (dy / distance) * curve;
const midY = (startY + endY) / 2 + (dx / distance) * curve;
return `M ${startX} ${startY} Q ${midX} ${midY} ${endX} ${endY}`;
}

View file

@ -0,0 +1,13 @@
export const MIN_NAVIGATOR_WIDTH = 220;
export const MIN_WORKBENCH_WIDTH = 420;
export function clampNavigatorWidth(
width: number,
containerWidth: number,
): number {
const maximum = Math.max(
MIN_NAVIGATOR_WIDTH,
containerWidth - MIN_WORKBENCH_WIDTH,
);
return Math.min(Math.max(MIN_NAVIGATOR_WIDTH, width), maximum);
}

1516
website/app/globals.css Normal file

File diff suppressed because it is too large Load diff

286
website/app/i18n.ts Normal file
View file

@ -0,0 +1,286 @@
"use client";
import { create } from "zustand";
export type Language = "zh" | "en";
const messages = {
zh: {
memoryWorkspace: "记忆工作区",
newAgentChat: "新建 Agent 对话",
newConversation: "新对话",
workspace: "工作区",
daily: "日记",
knowledgeBase: "知识库",
workspaceTab: "工作区",
chatTab: "对话",
dailyTab: "日记",
knowledgeTab: "知识库",
memoryGraph: "记忆图谱",
memoryGraphShort: "图谱",
memoryGraphLoading: "正在加载记忆图谱…",
memoryGraphLoadFailed: "记忆图谱加载失败",
memoryGraphEmpty: "这个分类还没有已索引的记忆链接",
memoryGraphCounts: "{nodes} 个节点 · {edges} 条链接",
memoryGraphZoomOut: "缩小",
memoryGraphZoomIn: "放大",
memoryGraphFit: "适应画布",
memoryGraphIndexed: "已索引文件",
memoryGraphDirection: "Wiki Link 方向",
memoryGraphOpenFile: "打开 Markdown",
memoryGraphOutbound: "出链 · {count}",
memoryGraphInbound: "入链 · {count}",
download: "下载",
loadingWorkspace: "读取工作区…",
connectionFailed: "无法连接 ReMe",
emptyWorkspace: "工作区暂无文件",
fileReadFailed: "文件读取失败",
edit: "编辑",
split: "分栏",
preview: "预览",
save: "保存",
saving: "保存中",
saved: "已保存",
saveFailed: "保存失败:{error}",
unknownError: "未知错误",
closeCurrentTab: "关闭当前文件",
closeOtherTabs: "关闭其他文件",
discardUnsavedConfirm: "将丢弃 {count} 个文件中未保存的修改,确定关闭吗?",
workspaceFileLimit:
"仅显示最近的 {limit} 个文件,工作区中可能还有其他文件。",
openingFile: "正在打开 {path}",
chatFailed: "对话失败",
chatTitle: "和你的记忆对话",
chatDescription: "搜索、阅读和整理 ReMe 工作区中的内容。",
promptRecent: "总结最近的记录",
promptTasks: "有哪些事项值得继续?",
promptIdeas: "查找关于 Agent Memory 的想法",
you: "你",
thinking: "思考过程",
dataChunk: "数据",
approvalChunk: "审批",
usageChunk: "模型用量",
unknownChunk: "事件 · {type}",
toolCall: "调用参数",
toolResult: "执行结果",
inputTokens: "输入",
outputTokens: "输出",
streaming: "进行中",
completed: "已完成",
askWorkspace: "询问你的工作区…",
send: "发送",
composerHint: "Enter 发送 · Shift + Enter 换行",
toggleNavigator: "切换文件导航",
resizeNavigator: "调整工作区导航宽度",
welcomeDescription: "从左侧打开 Markdown或者开始一段 Agent 对话。",
startChat: "开始对话",
localFiles: "文件保留在你的本地工作区",
switchLanguage: "切换语言",
appearance: "外观",
lightTheme: "浅色",
darkTheme: "深色",
systemTheme: "跟随系统",
documentation: "文档资料",
github: "GitHub",
settings: "设置",
settingsTitle: "ReMe 设置",
settingsDescription: "查看运行状态并管理本地工作区服务。",
settingsStatus: "状态",
settingsIndex: "索引",
settingsConfig: "配置",
settingsVersion: "版本",
closeSettings: "关闭设置",
refresh: "刷新",
loadingSettings: "正在读取…",
processMemory: "进程内存",
componentMemory: "组件内存",
componentDetails: "组件明细",
serviceOnline: "服务运行正常",
indexTitle: "工作区索引",
indexDescription: "从现有文件重新构建搜索索引。记忆文件不会被修改。",
rebuildIndex: "重建索引",
rebuildingIndex: "正在重建…",
confirmReindexTitle: "确定重建索引?",
confirmReindexDescription:
"现有派生索引会被清空,然后根据工作区文件重新生成。",
cancel: "取消",
confirmReindex: "确认重建",
indexRebuilt: "索引重建完成",
effectiveConfig: "当前生效配置",
redactedConfig: "敏感字段已由 ReMe 后端隐藏。",
currentVersion: "当前版本",
apiEndpoint: "服务地址",
invalidResponse: "ReMe 返回了无法解析的响应HTTP {status}",
requestFailed: "请求失败HTTP {status}",
agentUnavailable: "无法连接 AgentHTTP {status}",
},
en: {
memoryWorkspace: "Memory workspace",
newAgentChat: "New Agent chat",
newConversation: "New chat",
workspace: "Workspace",
daily: "Journal",
knowledgeBase: "Knowledge base",
workspaceTab: "Files",
chatTab: "Chat",
dailyTab: "Daily",
knowledgeTab: "Knowledge",
memoryGraph: "Memory graph",
memoryGraphShort: "Graph",
memoryGraphLoading: "Loading memory graph…",
memoryGraphLoadFailed: "Unable to load memory graph",
memoryGraphEmpty: "No indexed memory links in this category",
memoryGraphCounts: "{nodes} nodes · {edges} links",
memoryGraphZoomOut: "Zoom out",
memoryGraphZoomIn: "Zoom in",
memoryGraphFit: "Fit canvas",
memoryGraphIndexed: "Indexed file",
memoryGraphDirection: "Wiki Link direction",
memoryGraphOpenFile: "Open Markdown",
memoryGraphOutbound: "Outbound · {count}",
memoryGraphInbound: "Inbound · {count}",
download: "Download",
loadingWorkspace: "Loading workspace…",
connectionFailed: "Unable to connect to ReMe",
emptyWorkspace: "No files in this workspace",
fileReadFailed: "Unable to read file",
edit: "Edit",
split: "Split view",
preview: "Preview",
save: "Save",
saving: "Saving",
saved: "Saved",
saveFailed: "Save failed: {error}",
unknownError: "Unknown error",
closeCurrentTab: "Close current file",
closeOtherTabs: "Close other files",
discardUnsavedConfirm:
"Discard unsaved changes in {count} file(s) and close?",
workspaceFileLimit:
"Showing the {limit} most recent files. More files may exist in this workspace.",
openingFile: "Opening {path}",
chatFailed: "Chat failed",
chatTitle: "Chat with your memory",
chatDescription:
"Search, read, and organize content in your ReMe workspace.",
promptRecent: "Summarize my recent notes",
promptTasks: "What should I follow up on?",
promptIdeas: "Find my Agent Memory ideas",
you: "You",
thinking: "Thinking",
dataChunk: "Data",
approvalChunk: "Approval",
usageChunk: "Model usage",
unknownChunk: "Event · {type}",
toolCall: "Call",
toolResult: "Result",
inputTokens: "input",
outputTokens: "output",
streaming: "Streaming",
completed: "Completed",
askWorkspace: "Ask about your workspace…",
send: "Send",
composerHint: "Enter to send · Shift + Enter for a new line",
toggleNavigator: "Toggle file navigator",
resizeNavigator: "Resize workspace navigator",
welcomeDescription:
"Open a Markdown file from the left, or start an Agent conversation.",
startChat: "Start chatting",
localFiles: "Files stay in your local workspace",
switchLanguage: "Switch language",
appearance: "Appearance",
lightTheme: "Light",
darkTheme: "Dark",
systemTheme: "System",
documentation: "Documentation",
github: "GitHub",
settings: "Settings",
settingsTitle: "ReMe Settings",
settingsDescription:
"Inspect runtime status and manage the local workspace service.",
settingsStatus: "Status",
settingsIndex: "Index",
settingsConfig: "Configuration",
settingsVersion: "Version",
closeSettings: "Close settings",
refresh: "Refresh",
loadingSettings: "Loading…",
processMemory: "Process memory",
componentMemory: "Component memory",
componentDetails: "Component details",
serviceOnline: "Service is running",
indexTitle: "Workspace index",
indexDescription:
"Rebuild the search index from existing files. Memory files are not modified.",
rebuildIndex: "Rebuild index",
rebuildingIndex: "Rebuilding…",
confirmReindexTitle: "Rebuild the index?",
confirmReindexDescription:
"The derived index will be cleared and regenerated from workspace files.",
cancel: "Cancel",
confirmReindex: "Confirm rebuild",
indexRebuilt: "Index rebuilt",
effectiveConfig: "Effective configuration",
redactedConfig: "Sensitive fields are redacted by the ReMe backend.",
currentVersion: "Current version",
apiEndpoint: "Service endpoint",
invalidResponse: "ReMe returned an invalid response (HTTP {status})",
requestFailed: "Request failed (HTTP {status})",
agentUnavailable: "Unable to connect to Agent (HTTP {status})",
},
} as const;
export type TranslationKey = keyof typeof messages.zh;
export function translate(
language: Language,
key: TranslationKey,
values: Record<string, string> = {},
): string {
let text: string = messages[language][key];
for (const [name, value] of Object.entries(values))
text = text.replace(`{${name}}`, value);
return text;
}
interface LanguageState {
language: Language;
hydrate: () => void;
setLanguage: (language: Language) => void;
}
const applyLanguage = (language: Language) => {
if (typeof document !== "undefined")
document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
};
export const useLanguageStore = create<LanguageState>((set) => ({
language: "zh",
hydrate: () => {
const saved = localStorage.getItem("reme-language");
const language: Language =
saved === "zh" || saved === "en"
? saved
: navigator.language.startsWith("zh")
? "zh"
: "en";
applyLanguage(language);
set({ language });
},
setLanguage: (language) => {
localStorage.setItem("reme-language", language);
applyLanguage(language);
set({ language });
},
}));
export function useI18n() {
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
return {
language,
setLanguage,
t: (key: TranslationKey, values?: Record<string, string>) =>
translate(language, key, values),
};
}

67
website/app/layout.tsx Normal file
View file

@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host =
requestHeaders.get("x-forwarded-host") ||
requestHeaders.get("host") ||
"localhost:3000";
const protocol =
requestHeaders.get("x-forwarded-proto") ||
(host.startsWith("localhost") ? "http" : "https");
const metadataBase = new URL(`${protocol}://${host}`);
return {
metadataBase,
title: "ReMe Studio",
description:
"Browse, edit, and discuss your local-first ReMe memory workspace.",
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
openGraph: {
title: "ReMe Studio",
description: "本地优先的 Agent 记忆工作区",
images: [
{
url: "/og.png",
width: 1731,
height: 909,
alt: "ReMe Studio memory workspace",
},
],
},
twitter: {
card: "summary_large_image",
title: "ReMe Studio",
description: "本地优先的 Agent 记忆工作区",
images: ["/og.png"],
},
};
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("reme-theme")||"system";var d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch(e){}})()`,
}}
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}

View file

@ -0,0 +1,15 @@
import type { WorkspaceTab } from "./types";
type MarkdownTab = Extract<WorkspaceTab, { type: "markdown" }>;
export function markMarkdownContentSaved(
tab: MarkdownTab,
savedContent: string,
mtime?: string,
): MarkdownTab {
return {
...tab,
savedContent,
mtime: mtime ?? tab.mtime,
};
}

View file

@ -0,0 +1,25 @@
/// <reference types="vite/client" />
/** QwenPaw-compatible offline Monaco setup: bundle workers and never fetch editor code from a CDN. */
import { loader } from "@monaco-editor/react";
import * as monaco from "monaco-editor";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
if (typeof self !== "undefined") {
self.MonacoEnvironment = {
...self.MonacoEnvironment,
getWorker(_workerId: string, label: string) {
if (label === "json") return new jsonWorker();
if (["css", "scss", "less"].includes(label)) return new cssWorker();
if (["html", "handlebars", "razor"].includes(label))
return new htmlWorker();
if (["typescript", "javascript"].includes(label)) return new tsWorker();
return new editorWorker();
},
};
loader.config({ monaco });
}

5
website/app/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { ReMeWorkspace } from "./workspace";
export default function Home() {
return <ReMeWorkspace />;
}

View file

@ -0,0 +1,370 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
Activity,
BadgeInfo,
Braces,
Check,
CircleAlert,
DatabaseZap,
LoaderCircle,
RefreshCw,
X,
} from "lucide-react";
import {
getAppConfig,
getReMeStatus,
getReMeVersion,
rebuildReMeIndex,
REME_API_URL,
} from "./api";
import { useI18n } from "./i18n";
import type { AppConfig, ReMeResponse } from "./types";
type SettingsSection = "status" | "index" | "config" | "version";
interface MemoryUsage {
bytes?: number;
human?: string;
}
interface MemoryStatus {
components?: Record<string, Record<string, MemoryUsage>>;
components_total?: string;
process_rss?: string;
}
function memoryFrom(response?: ReMeResponse<string>): MemoryStatus | undefined {
const status = response?.metadata.status;
if (!status || typeof status !== "object") return undefined;
const memory = (status as Record<string, unknown>).memory;
return memory && typeof memory === "object"
? (memory as MemoryStatus)
: undefined;
}
function SettingsState({
loading,
error,
}: {
loading: boolean;
error?: string;
}) {
const { t } = useI18n();
if (loading)
return (
<div className="settings-state">
<LoaderCircle className="spin" size={18} />
{t("loadingSettings")}
</div>
);
if (error)
return (
<div className="settings-state error">
<CircleAlert size={18} />
{error}
</div>
);
return null;
}
export default function SettingsCenter({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { t } = useI18n();
const [section, setSection] = useState<SettingsSection>("status");
const [status, setStatus] = useState<ReMeResponse<string>>();
const [config, setConfig] = useState<AppConfig>();
const [version, setVersion] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [confirmingIndex, setConfirmingIndex] = useState(false);
const [reindexing, setReindexing] = useState(false);
const [indexResult, setIndexResult] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const [nextStatus, nextConfig, nextVersion] = await Promise.all([
getReMeStatus(),
getAppConfig(),
getReMeVersion(),
]);
setStatus(nextStatus);
setConfig(nextConfig);
setVersion(nextVersion);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : "ReMe unavailable",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => void load());
return () => cancelAnimationFrame(frame);
}, [load, open]);
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", closeOnEscape);
return () => document.removeEventListener("keydown", closeOnEscape);
}, [onClose, open]);
const rebuild = async () => {
setConfirmingIndex(false);
setReindexing(true);
setIndexResult("");
setError("");
try {
const result = await rebuildReMeIndex();
setIndexResult(
typeof result.answer === "string" && result.answer
? result.answer
: t("indexRebuilt"),
);
const nextStatus = await getReMeStatus();
setStatus(nextStatus);
} catch (nextError) {
setError(
nextError instanceof Error
? nextError.message
: t("requestFailed", { status: "" }),
);
} finally {
setReindexing(false);
}
};
const memory = memoryFrom(status);
const components = Object.entries(memory?.components || {}).flatMap(
([type, entries]) =>
Object.entries(entries).map(([name, usage]) => ({ type, name, usage })),
);
const sections: Array<{
id: SettingsSection;
label: string;
icon: React.ReactNode;
}> = [
{ id: "status", label: t("settingsStatus"), icon: <Activity size={18} /> },
{ id: "index", label: t("settingsIndex"), icon: <DatabaseZap size={18} /> },
{ id: "config", label: t("settingsConfig"), icon: <Braces size={18} /> },
{
id: "version",
label: t("settingsVersion"),
icon: <BadgeInfo size={18} />,
},
];
return (
<div
className={`settings-overlay ${open ? "open" : ""}`}
aria-hidden={!open}
inert={!open}
onPointerDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<section
className="settings-window"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
>
<header className="settings-header">
<div>
<h2 id="settings-title">{t("settingsTitle")}</h2>
<p>{t("settingsDescription")}</p>
</div>
<button
onClick={onClose}
aria-label={t("closeSettings")}
title={t("closeSettings")}
>
<X size={18} />
</button>
</header>
<div className="settings-layout">
<nav className="settings-sidebar" aria-label={t("settingsTitle")}>
{sections.map((item) => (
<button
key={item.id}
className={section === item.id ? "active" : ""}
onClick={() => {
setSection(item.id);
setError("");
}}
>
<span>{item.icon}</span>
{item.label}
</button>
))}
</nav>
<main className="settings-content">
{section !== "index" && (
<button
className="settings-refresh"
onClick={() => void load()}
disabled={loading}
>
<RefreshCw className={loading ? "spin" : ""} size={15} />
{t("refresh")}
</button>
)}
<SettingsState
loading={loading && !status && !config && !version}
error={section === "index" ? undefined : error}
/>
{section === "status" && status && (
<div className="settings-page">
<div className="settings-page-title">
<span className="settings-page-icon status">
<Activity size={25} />
</span>
<div>
<h3>{t("settingsStatus")}</h3>
<p>
<i className="status-dot" />
{t("serviceOnline")}
</p>
</div>
</div>
<div className="status-metrics">
<article>
<small>{t("processMemory")}</small>
<strong>{memory?.process_rss || "—"}</strong>
</article>
<article>
<small>{t("componentMemory")}</small>
<strong>{memory?.components_total || "—"}</strong>
</article>
</div>
<section className="settings-card">
<h4>{t("componentDetails")}</h4>
{components.map(({ type, name, usage }) => (
<div className="component-row" key={`${type}:${name}`}>
<span>
{type}
<small>{name}</small>
</span>
<strong>{usage.human || "—"}</strong>
</div>
))}
</section>
</div>
)}
{section === "index" && (
<div className="settings-page">
<div className="settings-page-title">
<span className="settings-page-icon index">
<DatabaseZap size={25} />
</span>
<div>
<h3>{t("indexTitle")}</h3>
<p>{t("indexDescription")}</p>
</div>
</div>
<section className="settings-card index-card">
{!confirmingIndex ? (
<button
className="primary-action"
onClick={() => setConfirmingIndex(true)}
disabled={reindexing}
>
{reindexing ? (
<LoaderCircle className="spin" size={16} />
) : (
<DatabaseZap size={16} />
)}
{reindexing ? t("rebuildingIndex") : t("rebuildIndex")}
</button>
) : (
<div className="index-confirm">
<div>
<strong>{t("confirmReindexTitle")}</strong>
<p>{t("confirmReindexDescription")}</p>
</div>
<div>
<button onClick={() => setConfirmingIndex(false)}>
{t("cancel")}
</button>
<button
className="danger-action"
onClick={() => void rebuild()}
>
{t("confirmReindex")}
</button>
</div>
</div>
)}
{indexResult && (
<div className="settings-success">
<Check size={16} />
{indexResult}
</div>
)}
{error && (
<div className="settings-state error">
<CircleAlert size={18} />
{error}
</div>
)}
</section>
</div>
)}
{section === "config" && config && (
<div className="settings-page">
<div className="settings-page-title">
<span className="settings-page-icon config">
<Braces size={25} />
</span>
<div>
<h3>{t("effectiveConfig")}</h3>
<p>{t("redactedConfig")}</p>
</div>
</div>
<pre className="config-json">
{JSON.stringify(config, null, 2)}
</pre>
</div>
)}
{section === "version" && version && (
<div className="settings-page">
<div className="settings-page-title">
<span className="settings-page-icon version">
<BadgeInfo size={25} />
</span>
<div>
<h3>{t("settingsVersion")}</h3>
<p>{t("currentVersion")}</p>
</div>
</div>
<section className="settings-card version-card">
<div>
<small>ReMe</small>
<strong>v{version}</strong>
</div>
<div>
<small>{t("apiEndpoint")}</small>
<code>{REME_API_URL}</code>
</div>
</section>
</div>
)}
</main>
</div>
</section>
</div>
);
}

257
website/app/store.ts Normal file
View file

@ -0,0 +1,257 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
applyStreamChunk,
finishChatMessage,
toggleChatBlock,
} from "./chat-stream";
import type {
ChatMessage,
MemoryGraphRoot,
StreamChunk,
WorkspaceTab,
} from "./types";
import {
prepareWorkspaceSnapshot,
type PersistedWorkspaceState,
} from "./workspace-persistence";
import { markMarkdownContentSaved } from "./markdown-save";
import { unsavedTabsClosedBy } from "./tab-close";
interface WorkspaceState {
tabs: WorkspaceTab[];
activeTabId?: string;
openMarkdown: (path: string) => string;
openAgent: () => string;
openGraph: (root: MemoryGraphRoot) => string;
closeTab: (id: string, discardUnsaved?: boolean) => void;
closeOtherTabs: (id: string, discardUnsaved?: boolean) => void;
setActiveTab: (id: string) => void;
hydrateMarkdown: (id: string, content: string, mtime?: string) => void;
failMarkdown: (id: string, error: string) => void;
updateMarkdown: (id: string, content: string) => void;
markSaved: (id: string, content: string, mtime?: string) => void;
addChatTurn: (
tabId: string,
user: ChatMessage,
assistant: ChatMessage,
) => void;
applyChatChunk: (
tabId: string,
messageId: string,
chunk: StreamChunk,
) => void;
toggleChatBlock: (
tabId: string,
messageId: string,
blockId: string,
expanded: boolean,
) => void;
finishChat: (tabId: string, messageId: string, error?: string) => void;
}
const fileTitle = (path: string) => path.split("/").pop() || path;
// Preserve the original key so existing tabs, chats, and unsaved drafts survive the Studio rename.
const WORKSPACE_STORAGE_KEY = "reme-workspace";
export const useWorkspaceStore = create<WorkspaceState>()(
persist(
(set, get) => ({
tabs: [],
activeTabId: undefined,
openMarkdown: (path) => {
const existing = get().tabs.find(
(tab) => tab.type === "markdown" && tab.path === path,
);
if (existing) {
set({ activeTabId: existing.id });
return existing.id;
}
const id = `file:${path}`;
set((state) => ({
tabs: [
...state.tabs,
{
id,
type: "markdown",
title: fileTitle(path),
path,
content: "",
savedContent: "",
loading: true,
},
],
activeTabId: id,
}));
return id;
},
openAgent: () => {
const id = `agent:${crypto.randomUUID()}`;
set((state) => ({
tabs: [...state.tabs, { id, type: "agent", title: "", messages: [] }],
activeTabId: id,
}));
return id;
},
openGraph: (root) => {
const id = `graph:${root}`;
const existing = get().tabs.find(
(tab) => tab.id === id && tab.type === "graph",
);
if (existing) {
set({ activeTabId: id });
return id;
}
set((state) => ({
tabs: [
...state.tabs,
{ id, type: "graph", title: `${root} graph`, root },
],
activeTabId: id,
}));
return id;
},
closeTab: (id, discardUnsaved = false) =>
set((state) => {
if (
!discardUnsaved &&
unsavedTabsClosedBy(state.tabs, id, false).length
)
return state;
const index = state.tabs.findIndex((tab) => tab.id === id);
const tabs = state.tabs.filter((tab) => tab.id !== id);
const activeTabId =
state.activeTabId === id
? tabs[Math.max(0, index - 1)]?.id
: state.activeTabId;
return { tabs, activeTabId };
}),
closeOtherTabs: (id, discardUnsaved = false) =>
set((state) => {
if (
!discardUnsaved &&
unsavedTabsClosedBy(state.tabs, id, true).length
)
return state;
const tab = state.tabs.find((item) => item.id === id);
return tab ? { tabs: [tab], activeTabId: id } : state;
}),
setActiveTab: (activeTabId) => set({ activeTabId }),
hydrateMarkdown: (id, content, mtime) =>
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === id && tab.type === "markdown"
? {
...tab,
content,
savedContent: content,
mtime,
loading: false,
error: undefined,
}
: tab,
),
})),
failMarkdown: (id, error) =>
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === id && tab.type === "markdown"
? { ...tab, loading: false, error }
: tab,
),
})),
updateMarkdown: (id, content) =>
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === id && tab.type === "markdown"
? { ...tab, content }
: tab,
),
})),
markSaved: (id, content, mtime) =>
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === id && tab.type === "markdown"
? markMarkdownContentSaved(tab, content, mtime)
: tab,
),
})),
addChatTurn: (tabId, user, assistant) =>
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === tabId && tab.type === "agent"
? {
...tab,
title: tab.messages.length
? tab.title
: user.content.slice(0, 18),
messages: [...tab.messages, user, assistant],
streaming: true,
}
: tab,
),
})),
applyChatChunk: (tabId, messageId, chunk) =>
set((state) => ({
tabs: state.tabs.map((tab) => {
if (tab.id !== tabId || tab.type !== "agent") return tab;
const messages = tab.messages.map((message) =>
message.id === messageId
? applyStreamChunk(message, chunk)
: message,
);
return {
...tab,
sessionId: chunk.session_id || tab.sessionId,
messages,
};
}),
})),
toggleChatBlock: (tabId, messageId, blockId, expanded) =>
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === tabId && tab.type === "agent"
? {
...tab,
messages: tab.messages.map((message) =>
message.id === messageId
? toggleChatBlock(message, blockId, expanded)
: message,
),
}
: tab,
),
})),
finishChat: (tabId, messageId, error) =>
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === tabId && tab.type === "agent"
? {
...tab,
streaming: false,
messages: tab.messages.map((message) =>
message.id === messageId
? finishChatMessage(message, error)
: message,
),
}
: tab,
),
})),
}),
{
name: WORKSPACE_STORAGE_KEY,
version: 1,
storage: createJSONStorage(() => localStorage),
skipHydration: true,
partialize: (state): PersistedWorkspaceState =>
prepareWorkspaceSnapshot(state.tabs, state.activeTabId),
merge: (persisted, current) => ({
...current,
...(persisted as PersistedWorkspaceState),
}),
},
),
);

17
website/app/tab-close.ts Normal file
View file

@ -0,0 +1,17 @@
import type { WorkspaceTab } from "./types";
export function hasUnsavedChanges(tab: WorkspaceTab): boolean {
return tab.type === "markdown" && tab.content !== tab.savedContent;
}
export function unsavedTabsClosedBy(
tabs: WorkspaceTab[],
tabId: string,
closeOthers: boolean,
): WorkspaceTab[] {
return tabs.filter(
(tab) =>
hasUnsavedChanges(tab) &&
(closeOthers ? tab.id !== tabId : tab.id === tabId),
);
}

57
website/app/theme.ts Normal file
View file

@ -0,0 +1,57 @@
"use client";
import { create } from "zustand";
export type ThemePreference = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
const STORAGE_KEY = "reme-theme";
let listeningForSystemTheme = false;
const resolveTheme = (preference: ThemePreference): ResolvedTheme =>
preference === "system" && typeof window !== "undefined"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: preference === "dark"
? "dark"
: "light";
const applyTheme = (preference: ThemePreference) => {
const resolved = resolveTheme(preference);
if (typeof document !== "undefined")
document.documentElement.dataset.theme = resolved;
return resolved;
};
interface ThemeState {
preference: ThemePreference;
resolved: ResolvedTheme;
hydrate: () => void;
setPreference: (preference: ThemePreference) => void;
}
export const useThemeStore = create<ThemeState>((set, get) => ({
preference: "system",
resolved: "light",
hydrate: () => {
const saved = localStorage.getItem(STORAGE_KEY);
const preference: ThemePreference =
saved === "light" || saved === "dark" || saved === "system"
? saved
: "system";
set({ preference, resolved: applyTheme(preference) });
if (listeningForSystemTheme) return;
listeningForSystemTheme = true;
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
if (get().preference === "system")
set({ resolved: applyTheme("system") });
});
},
setPreference: (preference) => {
localStorage.setItem(STORAGE_KEY, preference);
set({ preference, resolved: applyTheme(preference) });
},
}));

157
website/app/types.ts Normal file
View file

@ -0,0 +1,157 @@
export interface ReMeResponse<TAnswer = unknown> {
answer: TAnswer;
success: boolean;
metadata: Record<string, unknown>;
}
export interface AppConfig {
app_name: string;
workspace_dir: string;
daily_dir: string;
digest_dir: string;
resource_dir: string;
[key: string]: unknown;
}
export interface FileStat {
path: string;
exists: boolean;
type: "file" | "dir";
mtime?: string;
size?: number;
mime?: string;
frontmatter?: Record<string, unknown>;
}
export interface TreeNode {
name: string;
path: string;
type: "directory" | "file";
children: TreeNode[];
}
export type WorkspaceSource = "workspace" | "daily" | "digest";
export type MemoryGraphRoot = "wiki" | "personal" | "procedure";
export interface GraphSnapshotNode {
id: string;
path: string;
name: string;
description: string;
indexed: boolean;
virtual: boolean;
}
export interface GraphSnapshotEdge {
source: string;
target: string;
target_anchor: string | null;
}
export interface GraphSnapshot {
version: 1;
nodes: GraphSnapshotNode[];
edges: GraphSnapshotEdge[];
}
export type StreamChunkType =
| "reply_start"
| "reply_end"
| "think"
| "content"
| "data"
| "tool_call"
| "tool_result"
| "approval"
| "usage"
| "error"
| "done";
export type StreamPayload = string | Record<string, unknown> | unknown[];
export interface StreamChunk {
chunk_type: StreamChunkType | (string & {});
chunk: StreamPayload;
done: boolean;
session_id?: string;
block_id?: string;
tool_call_id?: string;
tool_call_name?: string;
media_type?: string;
input_tokens?: number;
output_tokens?: number;
metadata?: Record<string, unknown>;
}
export interface ContentBlock {
id: string;
type: "content";
text: string;
}
export interface DetailBlock {
id: string;
type: "think" | "data" | "approval" | "usage" | "unknown";
sourceType: string;
payloads: StreamPayload[];
status: "streaming" | "done" | "error";
expanded: boolean;
mediaType?: string;
inputTokens?: number;
outputTokens?: number;
metadata?: Record<string, unknown>;
}
export interface ToolBlock {
id: string;
type: "tool";
name: string;
callPayloads: StreamPayload[];
resultPayloads: StreamPayload[];
status: "calling" | "running" | "done" | "error";
expanded: boolean;
mediaType?: string;
metadata?: Record<string, unknown>;
}
export interface ErrorBlock {
id: string;
type: "error";
text: string;
}
export type ChatBlock = ContentBlock | DetailBlock | ToolBlock | ErrorBlock;
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
blocks?: ChatBlock[];
}
export type WorkspaceTab =
| {
id: string;
type: "markdown";
title: string;
path: string;
content: string;
savedContent: string;
mtime?: string;
loading?: boolean;
error?: string;
}
| {
id: string;
type: "agent";
title: string;
sessionId?: string;
messages: ChatMessage[];
streaming?: boolean;
}
| {
id: string;
type: "graph";
title: string;
root: MemoryGraphRoot;
};

View file

@ -0,0 +1,21 @@
export const WORKSPACE_FILE_DRAG_TYPE = "application/x-reme-workspace-file";
export function absoluteWorkspacePath(
workspaceDir: string,
relativePath: string,
): string {
const root = workspaceDir.trim().replace(/[\\/]+$/, "");
const relative = relativePath.trim().replace(/^[\\/]+/, "");
const separator = root.includes("\\") && !root.includes("/") ? "\\" : "/";
return `${root}${separator}${relative.replace(/[\\/]+/g, separator)}`;
}
export function appendWorkspaceFileReference(
input: string,
absolutePath: string,
): string {
const reference = `\`${absolutePath.replace(/`/g, "\\`")}\``;
if (input.includes(reference)) return input;
const current = input.trimEnd();
return `${current}${current ? "\n" : ""}${reference}`;
}

View file

@ -0,0 +1,109 @@
import type { TreeNode } from "./types";
const defaults = ["md", "txt"];
export const WORKSPACE_FILE_LIMIT = 5000;
export interface WorkspaceFileListing {
paths: string[];
limited: boolean;
}
export function workspaceFileListing(
items: unknown,
limit = WORKSPACE_FILE_LIMIT,
): WorkspaceFileListing {
const paths = Array.isArray(items)
? items.filter((item): item is string => typeof item === "string")
: [];
return { paths, limited: paths.length >= limit };
}
export type WorkspaceDirectoryConfig = {
daily_dir: string;
digest_dir: string;
};
export type WorkspaceFileSource = "workspace" | "daily" | "digest";
export function parseWorkspaceExtensions(value?: string): Set<string> {
const extensions = (value || "")
.split(",")
.map((item) => item.trim().toLowerCase().replace(/^\./, ""))
.filter(Boolean);
return new Set(extensions.length ? extensions : defaults);
}
export function filterWorkspacePaths(
paths: string[],
extensions: Set<string>,
): string[] {
return paths.filter((path) => {
const parts = path.split("/");
const extension = parts.at(-1)?.split(".").pop()?.toLowerCase() || "";
return (
parts.every((part) => part && !part.startsWith(".")) &&
extensions.has(extension)
);
});
}
/** Build a hierarchy while preserving the API's newest-modified-first path order. */
export function buildTree(
paths: string[],
extensions: Set<string>,
rootDirectory = "",
): TreeNode[] {
const root: TreeNode[] = [];
for (const path of filterWorkspacePaths(paths, extensions)) {
const relativePath =
rootDirectory && path.startsWith(`${rootDirectory}/`)
? path.slice(rootDirectory.length + 1)
: path;
const parts = relativePath.split("/").filter(Boolean);
let level = root;
parts.forEach((name, index) => {
const relativeNodePath = parts.slice(0, index + 1).join("/");
const nodePath = rootDirectory
? `${rootDirectory}/${relativeNodePath}`
: relativeNodePath;
let node = level.find((item) => item.name === name);
if (!node) {
node = {
name,
path: nodePath,
type: index === parts.length - 1 ? "file" : "directory",
children: [],
};
level.push(node);
}
level = node.children;
});
}
return root;
}
function cleanDirectory(value: string): string {
return value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
export function sourceDirectory(
source: WorkspaceFileSource,
config: WorkspaceDirectoryConfig,
): string {
if (source === "daily") return cleanDirectory(config.daily_dir);
if (source === "digest") return cleanDirectory(config.digest_dir);
return "";
}
export function filterPathsBySource(
paths: string[],
source: WorkspaceFileSource,
config: WorkspaceDirectoryConfig,
): string[] {
const directory = sourceDirectory(source, config);
if (!directory) return paths;
return paths.filter(
(path) => path === directory || path.startsWith(`${directory}/`),
);
}

View file

@ -0,0 +1,56 @@
import type { ChatBlock, ChatMessage, WorkspaceTab } from "./types";
export interface PersistedWorkspaceState {
tabs: WorkspaceTab[];
activeTabId?: string;
}
function finishInterruptedMessage(message: ChatMessage): ChatMessage {
const blocks = (message.blocks || []).map((block): ChatBlock => {
if (block.type === "content" || block.type === "error") return block;
return {
...block,
status: block.status === "error" ? "error" : "done",
expanded: false,
};
});
return { ...message, blocks };
}
/** Keep reopenable UI state without making cached files the workspace source of truth. */
export function prepareWorkspaceSnapshot(
tabs: WorkspaceTab[],
activeTabId?: string,
): PersistedWorkspaceState {
const persistedTabs = tabs.map((tab): WorkspaceTab => {
if (tab.type === "agent") {
return {
...tab,
streaming: false,
messages: tab.messages.map((message) =>
message.role === "assistant"
? finishInterruptedMessage(message)
: message,
),
};
}
if (tab.type === "graph") return tab;
const dirty = tab.content !== tab.savedContent;
if (dirty) return { ...tab, loading: false, error: undefined };
return {
...tab,
content: "",
savedContent: "",
loading: true,
error: undefined,
};
});
return {
tabs: persistedTabs,
activeTabId: persistedTabs.some((tab) => tab.id === activeTabId)
? activeTabId
: persistedTabs.at(-1)?.id,
};
}

755
website/app/workspace.tsx Normal file
View file

@ -0,0 +1,755 @@
"use client";
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
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 { getReMeVersion, 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";
import SettingsCenter from "./settings-center";
import { hasUnsavedChanges, unsavedTabsClosedBy } from "./tab-close";
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">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</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 { 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 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">
<Sparkles size={24} />
</div>
<h1>{t("chatTitle")}</h1>
<p>{t("chatDescription")}</p>
<div className="suggestions">
{[t("promptRecent"), t("promptTasks"), t("promptIdeas")].map(
(text) => (
<button key={text} onClick={() => setInput(text)}>
{text}
</button>
),
)}
</div>
</div>
)}
{tab.messages.map((message) => (
<div className={`message ${message.role}`} key={message.id}>
<div className="avatar">
{message.role === "user" ? (
t("you")
) : (
<span aria-label="ReMe" title="ReMe">
R
</span>
)}
</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>
))}
<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("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 [version, setVersion] = useState("");
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 () => {
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 controller = new AbortController();
void getReMeVersion()
.then((nextVersion) => {
if (!controller.signal.aborted) setVersion(nextVersion);
})
.catch(() => undefined);
return () => controller.abort();
}, []);
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>
ReMe Studio
{version && (
<>
<span className="app-version-divider" aria-hidden="true" />
<span className="app-version">v{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("documentation")}>
<a
href="https://docs.agentscope.io/reme/latest/en/overview"
target="_blank"
rel="noreferrer"
>
<BookOpenText size={17} />
<span>{t("documentation")}</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 />
<button
className="settings-trigger"
onClick={() => setSettingsOpen(true)}
aria-label={t("settings")}
title={t("settings")}
>
<Settings size={18} />
</button>
</div>
</header>
<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">
<div className="agent-logo">R</div>
<h1>ReMe Studio</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
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
</main>
);
}
export function ReMeWorkspace() {
return <Workspace />;
}

View file

@ -0,0 +1,45 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}

5
website/cloudflare-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare namespace Cloudflare {
interface Env {
DB: D1Database;
}
}

13
website/db/index.ts Normal file
View file

@ -0,0 +1,13 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database.",
);
}
return drizzle(env.DB, { schema });
}

4
website/db/schema.ts Normal file
View file

@ -0,0 +1,4 @@
// Intentionally empty by default.
// Add Drizzle tables here when the site actually needs a database.
// See examples/d1/db/schema.ts for an opt-in example.
export {};

View file

@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});

View file

@ -0,0 +1,5 @@
{
"version": "7",
"dialect": "sqlite",
"entries": []
}

35
website/eslint.config.mjs Normal file
View file

@ -0,0 +1,35 @@
import { defineConfig, globalIgnores } from "eslint/config";
import eslint from "@eslint/js";
import next from "@next/eslint-plugin-next";
import jsxA11y from "eslint-plugin-jsx-a11y";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
const eslintConfig = defineConfig([
globalIgnores([".next/**", "dist/**", "out/**", "build/**", "next-env.d.ts"]),
eslint.configs.recommended,
...tseslint.configs.recommended,
react.configs.flat.recommended,
react.configs.flat["jsx-runtime"],
reactHooks.configs.flat["recommended-latest"],
jsxA11y.flatConfigs.recommended,
next.configs["core-web-vitals"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.serviceworker,
},
},
settings: {
react: {
version: "detect",
},
},
},
]);
export default eslintConfig;

View file

@ -0,0 +1,63 @@
import { desc } from "drizzle-orm";
import { getDb } from "../../../../../db";
import { notes } from "../../../db/schema";
function toRouteErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
const detail =
error instanceof Error && error.cause instanceof Error
? error.cause.message
: "";
const combined = `${message}\n${detail}`;
if (combined.includes("no such table") || combined.includes('from "notes"')) {
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
}
return message;
}
export async function GET() {
try {
const db = getDb();
const rows = await db
.select()
.from(notes)
.orderBy(desc(notes.createdAt), desc(notes.id))
.limit(20);
return Response.json({ notes: rows });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
title?: string;
content?: string;
};
const title = payload.title?.trim() ?? "";
const content = payload.content?.trim() ?? "";
if (!title) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const db = getDb();
const [note] = await db
.insert(notes)
.values({ title, content })
.returning();
return Response.json({ note }, { status: 201 });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,11 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

5
website/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
import "vinext/types";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
website/next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11874
website/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

57
website/package.json Normal file
View file

@ -0,0 +1,57 @@
{
"name": "reme-studio",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev --force",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"test": "npm run build && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"format": "tsc --noEmit && prettier --write .",
"format:check": "tsc --noEmit && prettier --check .",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@agentscope-ai/icons": "^1.0.67",
"@monaco-editor/react": "^4.7.0",
"drizzle-orm": "0.45.2",
"lucide-react": "^1.28.0",
"monaco-editor": "^0.55.1",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@cloudflare/workers-types": "^4.20260702.1",
"@eslint/js": "9.39.4",
"@next/eslint-plugin-next": "16.2.6",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "16.4.0",
"prettier": "3.0.0",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"typescript-eslint": "8.59.3",
"vinext": "1.0.0-beta.2",
"vite": "8.0.13",
"wrangler": "4.92.0"
},
"type": "module"
}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<title>ReMe</title>
<rect width="32" height="32" rx="9" fill="#fff0e7"/>
<text
x="16"
y="22.5"
fill="#f36b21"
font-family="Arial, Helvetica, sans-serif"
font-size="20"
font-weight="800"
text-anchor="middle"
>R</text>
</svg>

After

Width:  |  Height:  |  Size: 321 B

1
website/public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 392 B

1
website/public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

BIN
website/public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 386 B

View file

@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
applyStreamChunk,
chatStreamError,
decodeSseEvent,
} from "../app/chat-stream.ts";
const initial = () => ({
id: "assistant",
role: "assistant",
content: "",
blocks: [],
});
const chunk = (chunk_type, value = "", extra = {}) => ({
chunk_type,
chunk: value,
done: false,
...extra,
});
test("chat chunks retain arrival order and correlate tool results", () => {
let message = initial();
message = applyStreamChunk(
message,
chunk("think", "checking", { block_id: "thought-1" }),
);
message = applyStreamChunk(
message,
chunk("tool_call", '{"path":"daily"}', {
tool_call_id: "tool-1",
tool_call_name: "search",
}),
);
message = applyStreamChunk(
message,
chunk("tool_result", ["daily/today.md"], { tool_call_id: "tool-1" }),
);
message = applyStreamChunk(
message,
chunk("content", "Found it.", { block_id: "answer-1" }),
);
message = applyStreamChunk(
message,
chunk(
"data",
{ matches: 1 },
{ block_id: "data-1", media_type: "application/json" },
),
);
assert.deepEqual(
message.blocks.map((block) => block.type),
["think", "tool", "content", "data"],
);
assert.equal(message.blocks[1].name, "search");
assert.deepEqual(message.blocks[1].resultPayloads, [["daily/today.md"]]);
assert.equal(message.blocks[2].text, "Found it.");
});
test("reply completion collapses every non-text block", () => {
let message = initial();
message = applyStreamChunk(
message,
chunk("think", "done", { block_id: "thought-1" }),
);
message = applyStreamChunk(
message,
chunk("usage", "", { input_tokens: 12, output_tokens: 4 }),
);
assert.ok(
message.blocks.every(
(block) =>
block.type === "content" || block.type === "error" || block.expanded,
),
);
message = applyStreamChunk(message, chunk("reply_end"));
assert.ok(
message.blocks.every(
(block) =>
block.type === "content" || block.type === "error" || !block.expanded,
),
);
assert.ok(
message.blocks.every(
(block) =>
block.type === "content" ||
block.type === "error" ||
block.status === "done",
),
);
});
test("reply completion restores the final answer when content deltas were missed", () => {
let message = initial();
message = applyStreamChunk(
message,
chunk("think", "checking", { block_id: "thought-1" }),
);
message = applyStreamChunk(
message,
chunk("reply_end", "", { metadata: { answer: "Final answer." } }),
);
assert.equal(message.blocks.at(-1).type, "content");
assert.equal(message.blocks.at(-1).text, "Final answer.");
assert.equal(message.blocks[0].status, "done");
});
test("content deltas with the same block id are combined", () => {
let message = initial();
message = applyStreamChunk(
message,
chunk("content", "Hello", { block_id: "answer" }),
);
message = applyStreamChunk(
message,
chunk("content", " world", { block_id: "answer" }),
);
assert.equal(message.blocks.length, 1);
assert.equal(message.blocks[0].text, "Hello world");
});
test("SSE terminal marker becomes an explicit done chunk", () => {
assert.deepEqual(decodeSseEvent("data:[DONE]"), {
chunk_type: "done",
chunk: "",
done: true,
});
assert.deepEqual(
decodeSseEvent('data:{"chunk_type":"content","chunk":"hi","done":false}'),
{
chunk_type: "content",
chunk: "hi",
done: false,
},
);
});
test("an aborted stream finishes without showing an error", async () => {
const controller = new AbortController();
controller.abort();
const error = await chatStreamError(
() => Promise.reject(new DOMException("Aborted", "AbortError")),
controller.signal,
"Chat failed",
);
assert.equal(error, undefined);
});
test("a failed stream returns its error message", async () => {
const controller = new AbortController();
const error = await chatStreamError(
() => Promise.reject(new Error("Connection lost")),
controller.signal,
"Chat failed",
);
assert.equal(error, "Connection lost");
});

View file

@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getLanguage } from "../app/files-workspace/get-language.ts";
import { parseMarkdownFrontmatter } from "../app/files-workspace/markdown.ts";
import { clampNavigatorWidth } from "../app/files-workspace/panel-resize.ts";
import { buildTree } from "../app/workspace-files.ts";
test("Monaco language mapping stays compatible with QwenPaw", () => {
assert.equal(getLanguage("src/page.tsx"), "typescript");
assert.equal(getLanguage("digest/topic.md"), "markdown");
assert.equal(getLanguage("notes/README"), "plaintext");
});
test("Markdown preview separates simple frontmatter like QwenPaw", () => {
assert.deepEqual(
parseMarkdownFrontmatter(
"---\nname: topic\ndescription: hello\n---\n# Body",
),
{
body: "# Body",
entries: [
{ key: "name", value: "topic" },
{ key: "description", value: "hello" },
],
},
);
});
test("Navigator resizing preserves usable widths for both panes", () => {
assert.equal(clampNavigatorWidth(100, 1200), 220);
assert.equal(clampNavigatorWidth(360, 1200), 360);
assert.equal(clampNavigatorWidth(1000, 1200), 780);
});
test("File tree preserves newest-modified-first ordering from the workspace API", () => {
const tree = buildTree(
[
"daily/older/note.md",
"digest/recent.md",
"daily/older/earliest.md",
"root-old.md",
],
new Set(["md"]),
);
assert.deepEqual(
tree.map((node) => node.path),
["daily", "digest", "root-old.md"],
);
assert.deepEqual(
tree[0].children[0].children.map((node) => node.name),
["note.md", "earliest.md"],
);
});

View file

@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { translate } from "../app/i18n.ts";
test("translations support Chinese, English, and interpolation", () => {
assert.equal(translate("zh", "newAgentChat"), "新建 Agent 对话");
assert.equal(translate("en", "newAgentChat"), "New Agent chat");
assert.equal(
translate("en", "openingFile", { path: "daily/today.md" }),
"Opening daily/today.md",
);
});

View file

@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import { markMarkdownContentSaved } from "../app/markdown-save.ts";
test("saving an older submission preserves edits made while the request was pending", () => {
const tab = {
id: "file:note.md",
type: "markdown",
title: "note.md",
path: "note.md",
content: "edited while saving",
savedContent: "before save",
mtime: "old-mtime",
};
const saved = markMarkdownContentSaved(tab, "submitted content", "new-mtime");
assert.equal(saved.content, "edited while saving");
assert.equal(saved.savedContent, "submitted content");
assert.equal(saved.mtime, "new-mtime");
assert.notEqual(saved.content, saved.savedContent);
});

View file

@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
edgePath,
graphBelowRoot,
layoutGraph,
reciprocalEdgeKeys,
} from "../app/files-workspace/memory-graph.ts";
const snapshot = {
version: 1,
nodes: [
{
id: "virtual:wiki",
path: "digest/wiki",
name: "wiki",
description: "",
indexed: false,
virtual: true,
},
{
id: "virtual:personal",
path: "digest/personal",
name: "personal",
description: "",
indexed: false,
virtual: true,
},
{
id: "digest/wiki/a.md",
path: "digest/wiki/a.md",
name: "A",
description: "",
indexed: true,
virtual: false,
},
{
id: "digest/personal/b.md",
path: "digest/personal/b.md",
name: "B",
description: "",
indexed: true,
virtual: false,
},
{
id: "daily/note.md",
path: "daily/note.md",
name: "Note",
description: "",
indexed: true,
virtual: false,
},
{
id: "virtual:procedure",
path: "digest/procedure",
name: "procedure",
description: "",
indexed: false,
virtual: true,
},
{
id: "digest/procedure/c.md",
path: "digest/procedure/c.md",
name: "C",
description: "",
indexed: true,
virtual: false,
},
{
id: "daily/unrelated.md",
path: "daily/unrelated.md",
name: "Unrelated",
description: "",
indexed: true,
virtual: false,
},
],
edges: [
{ source: "virtual:wiki", target: "digest/wiki/a.md", target_anchor: null },
{
source: "virtual:personal",
target: "digest/personal/b.md",
target_anchor: null,
},
{
source: "digest/wiki/a.md",
target: "daily/note.md",
target_anchor: null,
},
{
source: "digest/wiki/a.md",
target: "digest/personal/b.md",
target_anchor: null,
},
{
source: "digest/personal/b.md",
target: "digest/wiki/a.md",
target_anchor: null,
},
{
source: "virtual:procedure",
target: "digest/procedure/c.md",
target_anchor: null,
},
{
source: "digest/procedure/c.md",
target: "daily/unrelated.md",
target_anchor: null,
},
],
};
test("memory graph keeps only nodes reachable below the selected category", () => {
const graph = graphBelowRoot(snapshot, "wiki");
assert.deepEqual(
graph.nodes.map((node) => node.id),
[
"virtual:wiki",
"digest/wiki/a.md",
"digest/personal/b.md",
"daily/note.md",
],
);
assert.equal(graph.edges.length, 4);
});
test("memory graph excludes daily nodes below another category", () => {
const graph = graphBelowRoot(snapshot, "wiki");
assert.ok(!graph.nodes.some((node) => node.id === "daily/unrelated.md"));
assert.ok(!graph.nodes.some((node) => node.id === "digest/procedure/c.md"));
});
test("memory graph uses stable radial layers and curves reciprocal links", () => {
const graph = graphBelowRoot(snapshot, "wiki");
const positioned = layoutGraph(graph);
const root = positioned.byId.get("virtual:wiki");
const direct = positioned.byId.get("digest/wiki/a.md");
const leaf = positioned.byId.get("daily/note.md");
assert.deepEqual(
{ x: root.x, y: root.y, layer: root.layer },
{ x: 540, y: 340, layer: 0 },
);
assert.equal(direct.layer, 1);
assert.equal(leaf.layer, 2);
const reciprocal = reciprocalEdgeKeys(graph.edges);
const edge = graph.edges.find(
(item) =>
item.source === "digest/wiki/a.md" &&
item.target === "digest/personal/b.md",
);
assert.match(edgePath(edge, positioned.byId, reciprocal), / Q /);
});

View file

@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
async function render() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
return worker.fetch(
new Request("http://localhost/", { headers: { accept: "text/html" } }),
{
ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) },
},
{ waitUntil() {}, passThroughOnException() {} },
);
}
test("server-renders the ReMe Studio shell", async () => {
const response = await render();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, /<title>ReMe Studio<\/title>/i);
assert.match(html, /新建 Agent 对话/);
assert.match(html, /文件保留在你的本地工作区/);
assert.doesNotMatch(
html,
/codex-preview|Your site is taking shape|react-loading-skeleton/i,
);
});

View file

@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { hasUnsavedChanges, unsavedTabsClosedBy } from "../app/tab-close.ts";
const saved = {
id: "file:saved.md",
type: "markdown",
title: "saved.md",
path: "saved.md",
content: "saved",
savedContent: "saved",
};
const draft = {
...saved,
id: "file:draft.md",
path: "draft.md",
content: "unsaved draft",
};
const otherDraft = {
...draft,
id: "file:other.md",
path: "other.md",
};
test("tab close protection identifies only drafts that would be discarded", () => {
const tabs = [saved, draft, otherDraft];
assert.equal(hasUnsavedChanges(saved), false);
assert.equal(hasUnsavedChanges(draft), true);
assert.deepEqual(unsavedTabsClosedBy(tabs, draft.id, false), [draft]);
assert.deepEqual(unsavedTabsClosedBy(tabs, draft.id, true), [otherDraft]);
});

View file

@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
absoluteWorkspacePath,
appendWorkspaceFileReference,
} from "../app/workspace-drag.ts";
test("dragged workspace files use the ReMe service absolute path", () => {
assert.equal(
absoluteWorkspacePath("/Users/yuli/.reme/", "daily/2026-08-05.md"),
"/Users/yuli/.reme/daily/2026-08-05.md",
);
assert.equal(
absoluteWorkspacePath("C:\\ReMe\\workspace\\", "daily/entry.md"),
"C:\\ReMe\\workspace\\daily\\entry.md",
);
});
test("dropping a file appends one delimited reference without duplicates", () => {
const path = "/Users/yuli/My Memory/daily/today.md";
const first = appendWorkspaceFileReference("请总结", path);
assert.equal(first, "请总结\n`/Users/yuli/My Memory/daily/today.md`");
assert.equal(appendWorkspaceFileReference(first, path), first);
});

View file

@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
filterPathsBySource,
filterWorkspacePaths,
parseWorkspaceExtensions,
workspaceFileListing,
} from "../app/workspace-files.ts";
test("workspace filter hides dot paths and keeps configured file types", () => {
const paths = [
".DS_Store",
".hidden/note.md",
"daily/.draft.md",
"daily/note.md",
"digest/summary.TXT",
"resource/data.json",
];
assert.deepEqual(filterWorkspacePaths(paths, parseWorkspaceExtensions()), [
"daily/note.md",
"digest/summary.TXT",
]);
assert.deepEqual(
filterWorkspacePaths(paths, parseWorkspaceExtensions("json")),
["resource/data.json"],
);
});
test("workspace sources expose journal and knowledge files without an archive source", () => {
const paths = [
"daily/2026-08-05.md",
"digest/wiki/topic.md",
"notes/idea.md",
];
const config = { daily_dir: "daily", digest_dir: "digest" };
assert.deepEqual(filterPathsBySource(paths, "workspace", config), paths);
assert.deepEqual(filterPathsBySource(paths, "daily", config), [
"daily/2026-08-05.md",
]);
assert.deepEqual(filterPathsBySource(paths, "digest", config), [
"digest/wiki/topic.md",
]);
});
test("workspace listing reports when the service result reaches its limit", () => {
assert.deepEqual(workspaceFileListing(["a.md", "b.md"], 2), {
paths: ["a.md", "b.md"],
limited: true,
});
assert.deepEqual(workspaceFileListing(["a.md"], 2), {
paths: ["a.md"],
limited: false,
});
});

View file

@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import test from "node:test";
import { prepareWorkspaceSnapshot } from "../app/workspace-persistence.ts";
test("workspace snapshot reloads saved files but preserves unsaved drafts", () => {
const saved = {
id: "file:saved.md",
type: "markdown",
title: "saved.md",
path: "saved.md",
content: "saved",
savedContent: "saved",
};
const draft = {
id: "file:draft.md",
type: "markdown",
title: "draft.md",
path: "draft.md",
content: "draft",
savedContent: "saved",
};
const snapshot = prepareWorkspaceSnapshot([saved, draft], draft.id);
assert.deepEqual(snapshot.tabs[0], {
...saved,
content: "",
savedContent: "",
loading: true,
error: undefined,
});
assert.deepEqual(snapshot.tabs[1], {
...draft,
loading: false,
error: undefined,
});
assert.equal(snapshot.activeTabId, draft.id);
});
test("workspace snapshot keeps chat session and finishes interrupted blocks", () => {
const chat = {
id: "agent:one",
type: "agent",
title: "Chat",
sessionId: "session-1",
streaming: true,
messages: [
{
id: "assistant",
role: "assistant",
content: "",
blocks: [
{
id: "think:one",
type: "think",
sourceType: "think",
payloads: ["working"],
status: "streaming",
expanded: true,
},
],
},
],
};
const snapshot = prepareWorkspaceSnapshot([chat], chat.id);
const restored = snapshot.tabs[0];
assert.equal(restored.sessionId, "session-1");
assert.equal(restored.streaming, false);
assert.equal(restored.messages[0].blocks[0].status, "done");
assert.equal(restored.messages[0].blocks[0].expanded, false);
});
test("workspace snapshot preserves an open memory graph", () => {
const graph = {
id: "graph:wiki",
type: "graph",
title: "wiki graph",
root: "wiki",
};
const snapshot = prepareWorkspaceSnapshot([graph], graph.id);
assert.deepEqual(snapshot.tabs, [graph]);
assert.equal(snapshot.activeTabId, graph.id);
});

30
website/tsconfig.json Normal file
View file

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["@cloudflare/workers-types"],
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

59
website/vite.config.ts Normal file
View file

@ -0,0 +1,59 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});

65
website/worker/index.ts Normal file
View file

@ -0,0 +1,65 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import {
handleImageOptimization,
DEFAULT_DEVICE_SIZES,
DEFAULT_IMAGE_SIZES,
} from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: {
format: string;
quality: number;
}): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(
request,
{
fetchAsset: (path) =>
env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body)
.transform(width > 0 ? { width } : {})
.output({ format, quality });
return result.response();
},
},
allowedWidths,
);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;