mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
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:
parent
3924f89bb4
commit
b8f48c8004
67 changed files with 19272 additions and 0 deletions
41
.github/workflows/npm-format.yml
vendored
Normal file
41
.github/workflows/npm-format.yml
vendored
Normal 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
4
.gitignore
vendored
|
|
@ -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
2
website/.env.example
Normal 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
43
website/.gitignore
vendored
Normal 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
|
||||
5
website/.openai/hosting.json
Normal file
5
website/.openai/hosting.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"project_id": "appgprj_6a730f00ef248191914cf8ca92e120cb",
|
||||
"d1": null,
|
||||
"r2": null
|
||||
}
|
||||
18
website/.prettierignore
Normal file
18
website/.prettierignore
Normal 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
42
website/README.md
Normal 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
143
website/app/api.ts
Normal 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
269
website/app/chat-stream.ts
Normal 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("");
|
||||
}
|
||||
90
website/app/chatgpt-auth.ts
Normal file
90
website/app/chatgpt-auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
101
website/app/files-workspace/FilePreview.tsx
Normal file
101
website/app/files-workspace/FilePreview.tsx
Normal 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>;
|
||||
}
|
||||
356
website/app/files-workspace/FilesNavigator.tsx
Normal file
356
website/app/files-workspace/FilesNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
501
website/app/files-workspace/MemoryGraphView.tsx
Normal file
501
website/app/files-workspace/MemoryGraphView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
website/app/files-workspace/TabbedEditor.tsx
Normal file
158
website/app/files-workspace/TabbedEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
website/app/files-workspace/files-workspace.module.css
Normal file
193
website/app/files-workspace/files-workspace.module.css
Normal 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);
|
||||
}
|
||||
31
website/app/files-workspace/get-language.ts
Normal file
31
website/app/files-workspace/get-language.ts
Normal 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";
|
||||
}
|
||||
27
website/app/files-workspace/markdown.ts
Normal file
27
website/app/files-workspace/markdown.ts
Normal 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 };
|
||||
}
|
||||
384
website/app/files-workspace/memory-graph.module.css
Normal file
384
website/app/files-workspace/memory-graph.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
191
website/app/files-workspace/memory-graph.ts
Normal file
191
website/app/files-workspace/memory-graph.ts
Normal 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}`;
|
||||
}
|
||||
13
website/app/files-workspace/panel-resize.ts
Normal file
13
website/app/files-workspace/panel-resize.ts
Normal 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
1516
website/app/globals.css
Normal file
File diff suppressed because it is too large
Load diff
286
website/app/i18n.ts
Normal file
286
website/app/i18n.ts
Normal 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: "无法连接 Agent(HTTP {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
67
website/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
website/app/markdown-save.ts
Normal file
15
website/app/markdown-save.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
25
website/app/monaco-setup.ts
Normal file
25
website/app/monaco-setup.ts
Normal 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
5
website/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ReMeWorkspace } from "./workspace";
|
||||
|
||||
export default function Home() {
|
||||
return <ReMeWorkspace />;
|
||||
}
|
||||
370
website/app/settings-center.tsx
Normal file
370
website/app/settings-center.tsx
Normal 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
257
website/app/store.ts
Normal 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
17
website/app/tab-close.ts
Normal 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
57
website/app/theme.ts
Normal 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
157
website/app/types.ts
Normal 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;
|
||||
};
|
||||
21
website/app/workspace-drag.ts
Normal file
21
website/app/workspace-drag.ts
Normal 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}`;
|
||||
}
|
||||
109
website/app/workspace-files.ts
Normal file
109
website/app/workspace-files.ts
Normal 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}/`),
|
||||
);
|
||||
}
|
||||
56
website/app/workspace-persistence.ts
Normal file
56
website/app/workspace-persistence.ts
Normal 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
755
website/app/workspace.tsx
Normal 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 />;
|
||||
}
|
||||
45
website/build/sites-vite-plugin.ts
Normal file
45
website/build/sites-vite-plugin.ts
Normal 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
5
website/cloudflare-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
declare namespace Cloudflare {
|
||||
interface Env {
|
||||
DB: D1Database;
|
||||
}
|
||||
}
|
||||
13
website/db/index.ts
Normal file
13
website/db/index.ts
Normal 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
4
website/db/schema.ts
Normal 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 {};
|
||||
7
website/drizzle.config.ts
Normal file
7
website/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
});
|
||||
5
website/drizzle/meta/_journal.json
Normal file
5
website/drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": []
|
||||
}
|
||||
35
website/eslint.config.mjs
Normal file
35
website/eslint.config.mjs
Normal 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;
|
||||
63
website/examples/d1/app/api/notes/route.ts
Normal file
63
website/examples/d1/app/api/notes/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
11
website/examples/d1/db/schema.ts
Normal file
11
website/examples/d1/db/schema.ts
Normal 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
5
website/next-env.d.ts
vendored
Normal 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
7
website/next.config.ts
Normal 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
11874
website/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
57
website/package.json
Normal file
57
website/package.json
Normal 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"
|
||||
}
|
||||
7
website/postcss.config.mjs
Normal file
7
website/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
13
website/public/favicon.svg
Normal file
13
website/public/favicon.svg
Normal 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
1
website/public/file.svg
Normal 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
1
website/public/globe.svg
Normal 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
BIN
website/public/og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
1
website/public/window.svg
Normal file
1
website/public/window.svg
Normal 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 |
165
website/tests/chat-stream.test.mjs
Normal file
165
website/tests/chat-stream.test.mjs
Normal 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");
|
||||
});
|
||||
54
website/tests/files-workspace.test.mjs
Normal file
54
website/tests/files-workspace.test.mjs
Normal 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"],
|
||||
);
|
||||
});
|
||||
12
website/tests/i18n.test.mjs
Normal file
12
website/tests/i18n.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
22
website/tests/markdown-save.test.mjs
Normal file
22
website/tests/markdown-save.test.mjs
Normal 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);
|
||||
});
|
||||
156
website/tests/memory-graph.test.mjs
Normal file
156
website/tests/memory-graph.test.mjs
Normal 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 /);
|
||||
});
|
||||
31
website/tests/rendered-html.test.mjs
Normal file
31
website/tests/rendered-html.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
32
website/tests/tab-close.test.mjs
Normal file
32
website/tests/tab-close.test.mjs
Normal 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]);
|
||||
});
|
||||
25
website/tests/workspace-drag.test.mjs
Normal file
25
website/tests/workspace-drag.test.mjs
Normal 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);
|
||||
});
|
||||
56
website/tests/workspace-files.test.mjs
Normal file
56
website/tests/workspace-files.test.mjs
Normal 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,
|
||||
});
|
||||
});
|
||||
86
website/tests/workspace-persistence.test.mjs
Normal file
86
website/tests/workspace-persistence.test.mjs
Normal 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
30
website/tsconfig.json
Normal 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
59
website/vite.config.ts
Normal 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
65
website/worker/index.ts
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue