diff --git a/.github/workflows/npm-format.yml b/.github/workflows/npm-format.yml new file mode 100644 index 00000000..668b4570 --- /dev/null +++ b/.github/workflows/npm-format.yml @@ -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 diff --git a/.gitignore b/.gitignore index b5f00ce2..9370c953 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/website/.env.example b/website/.env.example new file mode 100644 index 00000000..70373034 --- /dev/null +++ b/website/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:2333 +NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS=md,txt diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 00000000..6c92d9d8 --- /dev/null +++ b/website/.gitignore @@ -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 diff --git a/website/.openai/hosting.json b/website/.openai/hosting.json new file mode 100644 index 00000000..978d0c6f --- /dev/null +++ b/website/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "project_id": "appgprj_6a730f00ef248191914cf8ca92e120cb", + "d1": null, + "r2": null +} diff --git a/website/.prettierignore b/website/.prettierignore new file mode 100644 index 00000000..b4a797c1 --- /dev/null +++ b/website/.prettierignore @@ -0,0 +1,18 @@ +# Build output +.next/ +.vinext/ +dist/ +out/ + +# Dependencies +node_modules/ + +# Cache and local state +.cache/ +.wrangler/ +*.cache + +# Misc +coverage/ +.vscode/ +.idea/ diff --git a/website/README.md b/website/README.md new file mode 100644 index 00000000..b9b7fbb8 --- /dev/null +++ b/website/README.md @@ -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 . 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 +``` diff --git a/website/app/api.ts b/website/app/api.ts new file mode 100644 index 00000000..0de232e2 --- /dev/null +++ b/website/app/api.ts @@ -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(response: Response): Promise> { + let payload: ReMeResponse; + try { + payload = (await response.json()) as ReMeResponse; + } 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( + action: string, + body: Record = {}, +): Promise> { + const response = await fetch(`${REME_API_URL}/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return parseResponse(response); +} + +export async function getAppConfig(): Promise { + return (await callReMe("app_config")).answer; +} + +export async function getReMeVersion(): Promise { + return String((await callReMe("version")).answer); +} + +export async function getReMeStatus(): Promise> { + return callReMe("status"); +} + +export async function rebuildReMeIndex(): Promise> { + return callReMe("reindex"); +} + +export async function getGraphSnapshot(): Promise { + return (await callReMe("graph_snapshot")).answer; +} + +export async function listWorkspaceFiles( + extensions: string[], +): Promise { + const response = await callReMe("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("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 { + const response = await callReMe("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 { + 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); +} diff --git a/website/app/chat-stream.ts b/website/app/chat-stream.ts new file mode 100644 index 00000000..381ad5aa --- /dev/null +++ b/website/app/chat-stream.ts @@ -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, + signal: AbortSignal, + fallback: string, +): Promise { + 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(""); +} diff --git a/website/app/chatgpt-auth.ts b/website/app/chatgpt-auth.ts new file mode 100644 index 00000000..a0ae2ed9 --- /dev/null +++ b/website/app/chatgpt-auth.ts @@ -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 { + 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 { + 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; + } +} diff --git a/website/app/files-workspace/FilePreview.tsx b/website/app/files-workspace/FilePreview.tsx new file mode 100644 index 00000000..9111c7fa --- /dev/null +++ b/website/app/files-workspace/FilePreview.tsx @@ -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 ( +
+ {entries.length > 0 && ( +
+ {entries.map(({ key, value }, index) => ( +
+
{key}
+
{value}
+
+ ))} +
+ )} + {body} +
+ ); +} + +function CsvPreview({ content }: { content: string }) { + const rows = useMemo(() => parseCsv(content), [content]); + const header = rows[0] ?? []; + return ( +
+ + + + {header.slice(0, 50).map((cell, index) => ( + + ))} + + + + {rows.slice(1, 501).map((row, rowIndex) => ( + + {row.slice(0, 50).map((cell, cellIndex) => ( + + ))} + + ))} + +
{cell}
{cell}
+
+ ); +} + +export default function FilePreview({ + filePath, + content, +}: { + filePath: string; + content: string; +}) { + const type = getPreviewType(filePath); + if (type === "markdown") return ; + if (type === "csv") return ; + return
{content}
; +} diff --git a/website/app/files-workspace/FilesNavigator.tsx b/website/app/files-workspace/FilesNavigator.tsx new file mode 100644 index 00000000..f018c976 --- /dev/null +++ b/website/app/files-workspace/FilesNavigator.tsx @@ -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 ( + <> +
+ + {graphRoot && ( + + )} +
+ {expanded && + node.children.map((child) => ( + + ))} + + ); + } + 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 ( + + ); +} + +/** 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) => void; + onResizeKeyDown: (event: React.KeyboardEvent) => void; +} + +export default function FilesNavigator({ + open, + width, + resizing, + onResizeStart, + onResizeKeyDown, +}: FilesNavigatorProps) { + const { t } = useI18n(); + const [config, setConfig] = useState(); + const [paths, setPaths] = useState([]); + const [limited, setLimited] = useState(false); + const [status, setStatus] = useState<"loading" | "ready" | "error">( + "loading", + ); + const [source, setSource] = useState("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 ( + + ); +} diff --git a/website/app/files-workspace/MemoryGraphView.tsx b/website/app/files-workspace/MemoryGraphView.tsx new file mode 100644 index 00000000..791473ef --- /dev/null +++ b/website/app/files-workspace/MemoryGraphView.tsx @@ -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, + 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(); + 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>({}); + const [draggingId, setDraggingId] = useState(""); + const dragSession = useRef(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(); + 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, 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) => { + 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) => { + 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 ( +
+ + {t("memoryGraphLoading")} +
+ ); + if (error && !snapshot) + return ( +
+ + {t("memoryGraphLoadFailed")} +
+ ); + + return ( +
+
+
+ + {t("memoryGraph")} · {root} + + + {t("memoryGraphCounts", { + nodes: String(graphSnapshot?.nodes.length || 0), + edges: String(graphSnapshot?.edges.length || 0), + })} + +
+
+ + + +
+
+ {!graph.nodes.length ? ( +
+ + {t("memoryGraphEmpty")} +
+ ) : ( +
+
+ + {t("memoryGraph")} + + + + + + + + + {graphSnapshot?.edges.map((edge) => { + const related = + edge.source === activeId || edge.target === activeId; + return ( + + + {edge.source} → {edge.target} + {edge.target_anchor ? `#${edge.target_anchor}` : ""} + + + ); + })} + + + {graph.nodes.map((node) => { + const active = node.id === activeId; + const related = neighbors.has(node.id); + return ( + = 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); + } + }} + > + + + {node.path} + + ); + })} + + + {graph.nodes + .filter( + (node) => labelIds.has(node.id) || node.id === activeId, + ) + .map((node) => ( + + {shortNodeLabel(node)} + + ))} + + + +
+ + + {t("memoryGraphIndexed")} + + + + {t("memoryGraphDirection")} + +
+
+ {selected && ( + + )} +
+ )} +
+ ); +} diff --git a/website/app/files-workspace/TabbedEditor.tsx b/website/app/files-workspace/TabbedEditor.tsx new file mode 100644 index 00000000..195488ea --- /dev/null +++ b/website/app/files-workspace/TabbedEditor.tsx @@ -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; + +/** 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 ( +
+ + {t("openingFile", { path: tab.path })} +
+ ); + if (tab.error) return
{tab.error}
; + + return ( +
+
+ {tab.path} + {saveError && ( + + {saveError} + + )} +
+
+ + +
+ + {!preview && ( + + )} +
+
+
+ {preview ? ( + + ) : ( + update(tab.id, value ?? "")} + options={{ + minimap: { enabled: false }, + fontSize: 13, + wordWrap: "on", + scrollBeyondLastLine: false, + automaticLayout: true, + }} + /> + )} +
+
+ ); +} diff --git a/website/app/files-workspace/files-workspace.module.css b/website/app/files-workspace/files-workspace.module.css new file mode 100644 index 00000000..469a3f12 --- /dev/null +++ b/website/app/files-workspace/files-workspace.module.css @@ -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); +} diff --git a/website/app/files-workspace/get-language.ts b/website/app/files-workspace/get-language.ts new file mode 100644 index 00000000..1d903da2 --- /dev/null +++ b/website/app/files-workspace/get-language.ts @@ -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 = { + 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"; +} diff --git a/website/app/files-workspace/markdown.ts b/website/app/files-workspace/markdown.ts new file mode 100644 index 00000000..6d24a118 --- /dev/null +++ b/website/app/files-workspace/markdown.ts @@ -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 }; +} diff --git a/website/app/files-workspace/memory-graph.module.css b/website/app/files-workspace/memory-graph.module.css new file mode 100644 index 00000000..ad017b24 --- /dev/null +++ b/website/app/files-workspace/memory-graph.module.css @@ -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; + } +} diff --git a/website/app/files-workspace/memory-graph.ts b/website/app/files-workspace/memory-graph.ts new file mode 100644 index 00000000..b4e9672e --- /dev/null +++ b/website/app/files-workspace/memory-graph.ts @@ -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; +} + +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(); + 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 { + 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(); + 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(); + 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 { + 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, + reciprocal: Set, +): 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}`; +} diff --git a/website/app/files-workspace/panel-resize.ts b/website/app/files-workspace/panel-resize.ts new file mode 100644 index 00000000..e701153d --- /dev/null +++ b/website/app/files-workspace/panel-resize.ts @@ -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); +} diff --git a/website/app/globals.css b/website/app/globals.css new file mode 100644 index 00000000..6192d3ec --- /dev/null +++ b/website/app/globals.css @@ -0,0 +1,1516 @@ +@import "tailwindcss"; +@import "monaco-editor/min/vs/editor/editor.main.css"; + +:root { + color-scheme: light; + --bg: #fffdfb; + --panel: #faf7f3; + --surface: #fff; + --subtle: #fbfaf8; + --topbar: #f9f8f4; + --hover: #f0ebe6; + --code-bg: #f3eee9; + --line: #e8e0d8; + --ink: #292522; + --muted: #7b746d; + --accent: #f36b21; + --soft: #fff0e7; + --danger: #d8433e; + --mono: var(--font-geist-mono), monospace; +} +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #171513; + --panel: #201d1a; + --surface: #24211e; + --subtle: #211e1b; + --topbar: #1c1917; + --hover: #302b27; + --code-bg: #302b27; + --line: #3b3530; + --ink: #eee9e4; + --muted: #aaa19a; + --soft: #432719; + --danger: #f07872; +} +* { + box-sizing: border-box; +} +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} +body, +button, +textarea { + font-family: var(--font-geist-sans), system-ui, sans-serif; +} +body { + color: var(--ink); + background: var(--bg); + font-size: 14px; +} +button { + color: inherit; +} +button:focus-visible, +textarea:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); + outline-offset: 2px; +} +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-thumb { + background: #d9d1ca; + border: 2px solid transparent; + border-radius: 8px; + background-clip: padding-box; +} +.shell { + height: 100vh; + display: flex; + flex-direction: column; +} +.topbar { + height: 56px; + flex: none; + display: grid; + grid-template-columns: 34px 180px 1fr auto; + align-items: center; + gap: 10px; + padding: 0 18px; + border-bottom: 1px solid var(--line); + background: var(--topbar); +} +.topbar > strong { + display: flex; + align-items: center; + font-size: 18px; + font-weight: 600; +} +.app-version-divider { + width: 1px; + height: 16px; + margin: 0 12px; + background: var(--line); +} +.app-version { + position: relative; + top: 2px; + color: var(--muted); + opacity: 0.62; + font-size: 12px !important; + font-weight: 600 !important; + line-height: 1; +} +.topbar > span { + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} +.topbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} +.language-switch { + height: 28px; + padding: 2px; + display: flex; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} +.language-switch button { + min-width: 34px; + padding: 0 7px; + border: 0; + border-radius: 5px; + color: var(--muted); + background: transparent; + font-size: 11px; + cursor: pointer; +} +.language-switch button.active { + color: var(--accent); + background: var(--soft); +} +.resource-links { + height: 32px; + display: flex; + align-items: center; + gap: 5px; +} +.resource-links a { + height: 32px; + padding: 0 8px; + display: flex; + align-items: center; + gap: 7px; + border-radius: 8px; + color: var(--ink); + font-size: 12px; + font-weight: 550; + text-decoration: none; + white-space: nowrap; +} +.resource-links a:hover { + background: var(--hover); +} +.resource-links i, +.topbar-divider { + width: 1px; + height: 16px; + flex: none; + background: var(--line); +} +.resource-links i { + margin: 0 2px; +} +.topbar-divider { + margin: 0 2px; +} +.menu { + width: 32px; + height: 32px; + padding: 0; + display: grid; + place-items: center; + border: 0; + border-radius: 8px; + color: var(--muted); + background: transparent; + cursor: pointer; + transition: + color 160ms, + background 160ms; +} +.menu:hover { + color: var(--ink); + background: var(--hover); +} +.menu-icons { + position: relative; + width: 20px; + height: 20px; + display: block; +} +.menu-icons .spark-icon { + position: absolute; + inset: 0; + transition: + opacity 180ms ease, + transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.menu-fold-icon { + opacity: 0; + transform: translateX(4px) scale(0.82); +} +.menu-expand-icon { + opacity: 1; + transform: translateX(0) scale(1); +} +.menu.is-open .menu-fold-icon { + opacity: 1; + transform: translateX(0) scale(1); +} +.menu.is-open .menu-expand-icon { + opacity: 0; + transform: translateX(-4px) scale(0.82); +} +.theme-menu { + position: relative; +} +.theme-menu > summary { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 9px; + color: var(--muted); + cursor: pointer; + list-style: none; +} +.theme-menu > summary::-webkit-details-marker { + display: none; +} +.theme-menu > summary:hover, +.theme-menu[open] > summary { + color: var(--ink); + background: var(--hover); +} +.theme-menu > div { + position: absolute; + z-index: 50; + top: 38px; + right: 0; + width: 142px; + padding: 6px; + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface); + box-shadow: 0 10px 30px rgba(35, 25, 18, 0.16); +} +.theme-menu button { + width: 100%; + height: 34px; + padding: 0 9px; + display: flex; + align-items: center; + gap: 9px; + border: 0; + border-radius: 7px; + color: var(--ink); + background: transparent; + cursor: pointer; + text-align: left; +} +.theme-menu button:hover, +.theme-menu button.active { + background: var(--hover); +} +.theme-menu button span { + flex: 1; +} +.settings-trigger { + width: 32px; + height: 32px; + padding: 0; + display: grid; + place-items: center; + border: 0; + border-radius: 9px; + color: var(--muted); + background: transparent; + cursor: pointer; + transition: + color 160ms, + background 160ms, + transform 200ms; +} +.settings-trigger:hover { + color: var(--ink); + background: var(--hover); +} +.settings-trigger:active { + transform: rotate(18deg); +} +.surface { + min-height: 0; + flex: 1; + display: flex; +} +.navigator { + position: relative; + width: var(--navigator-width, 260px); + flex: none; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--line); + background: var(--panel); + transition: + width 220ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 180ms ease; + will-change: width; +} +.navigator-content { + width: var(--navigator-width, 260px); + min-width: var(--navigator-width, 260px); + height: 100%; + display: flex; + flex-direction: column; + opacity: 1; + transform: translateX(0); + transition: + opacity 150ms ease, + transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.navigator-closed { + border-right-color: transparent; + pointer-events: none; +} +.navigator-closed .navigator-content { + opacity: 0; + transform: translateX(-12px); +} +.navigator-resizing { + transition: none; +} +.navigator-resize-handle { + position: absolute; + z-index: 20; + top: 0; + right: -4px; + bottom: 0; + width: 8px; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + touch-action: none; +} +.navigator-resize-handle::after { + content: ""; + position: absolute; + top: calc(50% - 24px); + right: 3px; + width: 2px; + height: 48px; + border-radius: 2px; + background: transparent; + transition: background 160ms; +} +.navigator-resize-handle:hover::after, +.navigator-resize-handle:focus-visible::after, +.navigator-resizing .navigator-resize-handle::after { + background: var(--accent); +} +.navigator-resizing, +.navigator-resizing * { + cursor: col-resize !important; + user-select: none !important; +} +.agent-logo { + display: grid; + place-items: center; + color: var(--accent); + background: var(--soft); + border-radius: 10px; +} +.source-tabs { + height: 36px; + padding: 0 8px; + display: flex; + align-items: center; + gap: 2px; + overflow-x: auto; + border-bottom: 1px solid var(--line); + scrollbar-width: none; +} +.source-tabs::-webkit-scrollbar { + display: none; +} +.source-tabs button { + height: 29px; + padding: 0 5px; + flex: 1 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + border: 0; + border-radius: 7px; + color: var(--muted); + background: transparent; + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} +.source-tabs button svg { + flex: none; +} +.source-tabs button:hover { + background: var(--hover); +} +.source-tabs button.active { + color: #756ae0; + background: color-mix(in srgb, #756ae0 15%, transparent); +} +.source-tabs .chat-tab { + height: 27px; + margin-left: 2px; + border: 1px solid #f2c7ad; + color: #9a471d; + background: #fff7f1; +} +.source-tabs .chat-tab:hover { + border-color: #e9aa85; + color: #843912; + background: var(--soft); +} +:root[data-theme="dark"] .source-tabs .chat-tab { + border-color: #70452f; + color: #f2a170; + background: #342319; +} +:root[data-theme="dark"] .source-tabs .chat-tab:hover { + border-color: #a9633d; + color: #ffc09a; +} +.tree { + min-height: 0; + flex: 1; + overflow: auto; + padding: 3px 7px 15px; +} +.tree-row { + width: 100%; + height: 29px; + padding-right: 8px; + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + border: 0; + border-radius: 7px; + background: transparent; + cursor: pointer; + text-align: left; + white-space: nowrap; +} +.tree-row[draggable="true"] { + cursor: grab; +} +.tree-row[draggable="true"]:active { + cursor: grabbing; +} +.tree-row span { + overflow: hidden; + text-overflow: ellipsis; +} +.tree-row svg { + flex: none; + color: var(--muted); +} +.tree-row:hover { + background: var(--hover); +} +.tree-row.selected { + color: var(--accent); + background: var(--soft); +} +.tree-row.selected svg { + color: var(--accent); +} +.tree-row:disabled { + opacity: 0.42; + cursor: default; +} +.tree-row:disabled:hover { + background: transparent; +} +.tree-directory-row { + display: flex; + align-items: center; + border-radius: 7px; +} +.tree-directory-row > .tree-row { + min-width: 0; + flex: 1; +} +.tree-directory-row.graph-active { + background: color-mix(in srgb, #756ae0 12%, transparent); +} +.tree-graph-button { + height: 23px; + margin-right: 4px; + padding: 0 6px; + flex: none; + display: flex; + align-items: center; + gap: 4px; + border: 0; + border-radius: 6px; + color: var(--muted); + background: transparent; + font-size: 9px; + cursor: pointer; +} +.tree-graph-button:hover, +.tree-directory-row.graph-active .tree-graph-button { + color: #756ae0; + background: color-mix(in srgb, #756ae0 13%, transparent); +} +.side-state { + padding: 18px 10px; + display: flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 12px; +} +.side-state.warning { + margin: 7px; + padding: 9px 10px; + border: 1px solid color-mix(in srgb, #b7791f 35%, var(--line)); + border-radius: 7px; + color: color-mix(in srgb, #b7791f 82%, var(--ink)); + background: color-mix(in srgb, #f6c453 12%, var(--surface)); +} +.workspace-path { + height: 50px; + padding: 0 14px; + display: flex; + align-items: center; + gap: 9px; + border-top: 1px solid var(--line); +} +.workspace-path > span:last-child { + overflow: hidden; + color: var(--muted); + font: 10px var(--mono); + text-overflow: ellipsis; + white-space: nowrap; +} +.status-dot { + width: 7px; + height: 7px; + flex: none; + border-radius: 50%; + background: #48a868; + box-shadow: 0 0 0 3px #e1f2e6; +} +.status-dot.offline { + background: var(--danger); + box-shadow: 0 0 0 3px #fae5e4; +} +:root[data-theme="dark"] .status-dot { + box-shadow: 0 0 0 3px #263d2d; +} +:root[data-theme="dark"] .status-dot.offline { + box-shadow: 0 0 0 3px #492725; +} +.workbench { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; +} +.tabs { + height: 40px; + flex: none; + display: flex; + overflow-x: auto; + border-bottom: 1px solid var(--line); + background: var(--subtle); +} +.tab { + min-width: 130px; + max-width: 210px; + padding: 0 10px 0 13px; + display: flex; + align-items: center; + gap: 7px; + border: 0; + border-right: 1px solid var(--line); + color: var(--muted); + background: transparent; + cursor: pointer; +} +.tab > span { + min-width: 0; + flex: 1; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} +.tab > svg:last-child { + color: var(--muted); + border-radius: 3px; +} +.tab > svg:last-child:hover { + color: var(--ink); + background: var(--hover); +} +.tab.active { + color: var(--ink); + background: var(--bg); + box-shadow: inset 0 2px var(--accent); +} +.dirty { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} +.tab-context-menu { + position: fixed; + z-index: 100; + width: 164px; + padding: 5px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--surface); + box-shadow: 0 10px 30px rgba(35, 25, 18, 0.18); +} +.tab-context-menu button { + width: 100%; + height: 32px; + padding: 0 10px; + border: 0; + border-radius: 6px; + color: var(--ink); + background: transparent; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.tab-context-menu button:hover { + background: var(--hover); +} +.tab-context-menu button:disabled { + color: var(--muted); + opacity: 0.45; + cursor: default; +} +.tab-context-menu button:disabled:hover { + background: transparent; +} +.content { + min-height: 0; + flex: 1; +} +.center, +.welcome { + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} +.center { + gap: 9px; + color: var(--muted); +} +.error { + color: var(--danger) !important; +} +.welcome { + flex-direction: column; +} +.welcome .agent-logo { + width: 58px; + height: 58px; + margin-bottom: 18px; + font-size: 26px; + font-weight: 750; +} +.welcome h1, +.chat-empty h1 { + margin: 0; + font-size: 22px; + letter-spacing: -0.03em; +} +.welcome p, +.chat-empty p { + margin: 8px 0 22px; + color: var(--muted); +} +.welcome > button { + height: 38px; + padding: 0 14px; + display: flex; + align-items: center; + gap: 8px; + border: 0; + border-radius: 9px; + color: #fff; + background: var(--accent); + cursor: pointer; +} +.welcome small { + margin-top: 30px; + color: #a29992; +} +.markdown { + max-width: 780px; + margin: 0 auto; + padding: 36px 42px 80px; + line-height: 1.75; +} +.markdown > *:first-child { + margin-top: 0; +} +.markdown h1, +.markdown h2, +.markdown h3 { + line-height: 1.3; +} +.markdown h1 { + font-size: 28px; +} +.markdown h2 { + margin-top: 1.6em; + padding-bottom: 0.35em; + border-bottom: 1px solid var(--line); + font-size: 21px; +} +.markdown a { + color: var(--accent); +} +.markdown code { + padding: 2px 5px; + border-radius: 5px; + background: var(--code-bg); + font: 12px var(--mono); +} +.markdown pre { + padding: 16px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); +} +.markdown pre code { + padding: 0; + background: transparent; +} +.markdown blockquote { + margin-left: 0; + padding-left: 16px; + border-left: 3px solid var(--accent); + color: var(--muted); +} +.chat { + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg); +} +.messages { + min-height: 0; + flex: 1; + overflow: auto; + padding: 34px max(24px, calc((100% - 760px) / 2)) 180px; +} +.chat-empty { + height: 100%; + min-height: 380px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} +.chat-empty .agent-logo { + width: 54px; + height: 54px; + margin-bottom: 18px; +} +.suggestions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} +.suggestions button { + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 18px; + color: var(--muted); + background: var(--surface); + cursor: pointer; +} +.suggestions button:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--soft); +} +.message { + margin: 0 auto 28px; + display: flex; + gap: 11px; +} +.avatar { + width: 30px; + height: 30px; + flex: none; + display: grid; + place-items: center; + border-radius: 9px; + color: #fff; + background: var(--accent); + font-size: 11px; +} +.message.assistant .avatar { + background: color-mix(in srgb, var(--accent) 82%, #fff); + font-size: 14px; + font-weight: 700; +} +.message.user .avatar { + color: var(--ink); + background: var(--hover); +} +.bubble { + min-width: 0; + flex: 1; + line-height: 1.7; +} +.message.user .bubble { + padding: 5px 12px; + border-radius: 12px; + background: var(--panel); +} +.bubble > p { + margin: 0; +} +.bubble .markdown { + max-width: none; + margin: 0; + padding: 2px 0; +} +.bubble details { + margin: 0 0 9px; + color: var(--muted); + font-size: 12px; +} +.bubble summary { + display: flex; + align-items: center; + gap: 7px; + cursor: pointer; +} +.bubble pre { + margin: 7px 0 0; + overflow: auto; + font: 11px/1.5 var(--mono); + white-space: pre-wrap; +} +.bubble pre.tool-result-scroll { + max-height: min(320px, 42vh); + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.stream-block { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--panel); +} +.stream-block[open] { + border-color: var(--muted); +} +.stream-block summary { + padding: 8px 10px; + list-style: none; +} +.stream-block summary::-webkit-details-marker { + display: none; +} +.stream-block summary span { + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; +} +.stream-block summary small { + flex: none; + color: var(--muted); + font-size: 10px; +} +.stream-block-body { + padding: 0 10px 9px 30px; + border-top: 1px solid transparent; +} +.stream-block-body section + section { + margin-top: 9px; + padding-top: 8px; + border-top: 1px solid var(--line); +} +.stream-block-body strong { + color: var(--muted); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} +.stream-block.think { + background: var(--subtle); +} +.stream-block.tool { + background: color-mix(in srgb, #756ae0 9%, var(--panel)); + border-color: color-mix(in srgb, #756ae0 35%, var(--line)); +} +.stream-block.data { + background: color-mix(in srgb, #48a868 8%, var(--panel)); + border-color: color-mix(in srgb, #48a868 35%, var(--line)); +} +.stream-block.approval { + background: color-mix(in srgb, #d49a2c 9%, var(--panel)); + border-color: color-mix(in srgb, #d49a2c 35%, var(--line)); +} +.stream-block.usage { + background: var(--subtle); +} +.stream-block.failed { + border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); + background: color-mix(in srgb, var(--danger) 8%, var(--panel)); +} +.chunk-error { + margin: 6px 0 9px; + padding: 8px 10px; + display: flex; + align-items: flex-start; + gap: 7px; + border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--line)); + border-radius: 8px; + color: var(--danger); + background: color-mix(in srgb, var(--danger) 8%, var(--panel)); + font-size: 12px; +} +.composer { + position: absolute; + right: 0; + bottom: 0; + left: var(--navigator-width, 260px); + padding: 12px max(24px, calc((100% - 1020px) / 2)) 16px; + background: linear-gradient(transparent, var(--bg) 25%); + transition: left 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.composer > div { + min-height: 104px; + padding: 12px 8px 9px 16px; + display: flex; + align-items: flex-end; + gap: 8px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); + box-shadow: 0 8px 28px rgba(35, 25, 18, 0.14); + transition: + border-color 0.15s, + box-shadow 0.15s, + background 0.15s; +} +.composer.file-drag-over > div { + border-color: var(--accent); + background: var(--soft); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent), + 0 8px 28px rgba(35, 25, 18, 0.14); +} +.composer textarea { + min-height: 78px; + max-height: 180px; + flex: 1; + resize: none; + border: 0; + outline: 0; + color: var(--ink); + background: transparent; +} +.composer button { + width: 36px; + height: 36px; + display: grid; + place-items: center; + flex: none; + border: 0; + border-radius: 10px; + color: #fff; + background: color-mix(in srgb, var(--accent) 82%, #fff); + cursor: pointer; +} +.composer button:disabled { + color: var(--muted); + background: var(--hover); +} +.composer > span { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 10px; + text-align: center; +} +.nav-closed .composer { + left: 0; +} +.navigator-is-resizing .composer { + transition: none; +} +.spin { + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .navigator, + .navigator-content, + .composer, + .menu-icons .spark-icon { + transition: none; + } +} +@media (max-width: 980px) { + .resource-links a span { + display: none; + } + .resource-links a { + padding: 0 7px; + } +} +@media (max-width: 760px) { + .topbar { + grid-template-columns: 34px 60px 1fr auto; + padding: 0 10px; + } + .resource-links, + .topbar-divider { + display: none; + } + .language-switch button { + min-width: 30px; + padding: 0 5px; + } + .navigator { + position: absolute; + top: 56px; + bottom: 0; + left: 0; + z-index: 10; + box-shadow: 16px 0 42px rgba(75, 49, 30, 0.16); + } + .navigator-resize-handle { + display: none; + } + .composer { + left: 0; + } +} + +.settings-overlay { + position: fixed; + z-index: 200; + inset: 0; + padding: 34px; + display: grid; + place-items: center; + opacity: 0; + pointer-events: none; + background: rgba(30, 25, 21, 0.26); + backdrop-filter: blur(5px); + transition: opacity 180ms ease; +} +.settings-overlay.open { + opacity: 1; + pointer-events: auto; +} +.settings-window { + width: min(1040px, 100%); + height: min(700px, 100%); + overflow: hidden; + display: flex; + flex-direction: column; + border: 1px solid color-mix(in srgb, var(--line) 82%, transparent); + border-radius: 20px; + background: var(--surface); + box-shadow: 0 28px 80px rgba(35, 25, 18, 0.26); + transform: translateY(12px) scale(0.985); + transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1); +} +.settings-overlay.open .settings-window { + transform: translateY(0) scale(1); +} +.settings-header { + height: 82px; + padding: 0 22px 0 26px; + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + background: var(--topbar); +} +.settings-header h2 { + margin: 0; + font-size: 19px; +} +.settings-header p { + margin: 5px 0 0; + color: var(--muted); + font-size: 11px; +} +.settings-header > button { + width: 32px; + height: 32px; + padding: 0; + display: grid; + place-items: center; + border: 0; + border-radius: 50%; + color: var(--muted); + background: var(--hover); + cursor: pointer; +} +.settings-header > button:hover { + color: var(--ink); +} +.settings-layout { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: 218px minmax(0, 1fr); +} +.settings-sidebar { + padding: 16px 12px; + border-right: 1px solid var(--line); + background: var(--panel); +} +.settings-sidebar button { + width: 100%; + height: 44px; + padding: 0 12px; + display: flex; + align-items: center; + gap: 11px; + border: 0; + border-radius: 10px; + color: var(--ink); + background: transparent; + font-size: 13px; + cursor: pointer; + text-align: left; +} +.settings-sidebar button + button { + margin-top: 4px; +} +.settings-sidebar button:hover { + background: var(--hover); +} +.settings-sidebar button.active { + color: #fff; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--accent) 82%, #fff), + var(--accent) + ); + box-shadow: 0 5px 14px color-mix(in srgb, var(--accent) 24%, transparent); +} +.settings-sidebar button > span { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border-radius: 7px; + background: color-mix(in srgb, var(--muted) 12%, transparent); +} +.settings-sidebar button.active > span { + background: rgba(255, 255, 255, 0.18); +} +.settings-content { + position: relative; + min-width: 0; + min-height: 0; + overflow: auto; + padding: 34px 40px 48px; + background: var(--bg); +} +.settings-refresh { + position: absolute; + z-index: 2; + top: 24px; + right: 28px; + height: 30px; + padding: 0 10px; + display: flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--muted); + background: var(--surface); + font-size: 11px; + cursor: pointer; +} +.settings-refresh:hover { + color: var(--ink); + background: var(--hover); +} +.settings-refresh:disabled { + opacity: 0.55; + cursor: default; +} +.settings-state { + min-height: 80px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--muted); +} +.settings-page { + max-width: 700px; + margin: 0 auto; +} +.settings-page-title { + min-height: 92px; + margin-bottom: 26px; + padding-right: 74px; + display: flex; + align-items: center; + gap: 16px; +} +.settings-page-title h3 { + margin: 0; + font-size: 22px; + letter-spacing: -0.02em; +} +.settings-page-title p { + margin: 6px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} +.settings-page-title p .status-dot { + display: inline-block; + margin-right: 8px; + vertical-align: 1px; +} +.settings-page-icon { + width: 58px; + height: 58px; + flex: none; + display: grid; + place-items: center; + border-radius: 15px; + color: #fff; + box-shadow: 0 8px 18px rgba(35, 25, 18, 0.14); +} +.settings-page-icon.status { + background: linear-gradient(145deg, #53b97a, #2f9b5b); +} +.settings-page-icon.index { + background: linear-gradient(145deg, #8b80e8, #7065d1); +} +.settings-page-icon.config { + background: linear-gradient(145deg, #f29a55, #e7762d); +} +.settings-page-icon.version { + background: linear-gradient(145deg, #6ca7dc, #477fb7); +} +.status-metrics { + margin-bottom: 16px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.status-metrics article { + min-height: 112px; + padding: 20px; + display: flex; + flex-direction: column; + justify-content: space-between; + border: 1px solid var(--line); + border-radius: 15px; + background: var(--surface); + box-shadow: 0 5px 18px rgba(35, 25, 18, 0.05); +} +.status-metrics small, +.version-card small { + color: var(--muted); + font-size: 11px; +} +.status-metrics strong { + font-size: 25px; + letter-spacing: -0.03em; +} +.settings-card { + padding: 8px 18px; + border: 1px solid var(--line); + border-radius: 15px; + background: var(--surface); + box-shadow: 0 5px 18px rgba(35, 25, 18, 0.05); +} +.settings-card h4 { + margin: 8px 0 10px; + font-size: 12px; +} +.component-row { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-top: 1px solid var(--line); +} +.component-row > span { + display: flex; + flex-direction: column; + font-size: 12px; +} +.component-row small { + margin-top: 2px; + color: var(--muted); + font-size: 10px; +} +.component-row strong { + font-size: 12px; +} +.index-card { + padding: 22px; +} +.primary-action, +.danger-action, +.index-confirm button { + height: 36px; + padding: 0 13px; + border-radius: 9px; + cursor: pointer; +} +.primary-action { + display: flex; + align-items: center; + gap: 8px; + border: 0; + color: #fff; + background: var(--accent); +} +.primary-action:disabled { + opacity: 0.6; + cursor: default; +} +.index-confirm { + display: flex; + align-items: center; + justify-content: space-between; + gap: 22px; +} +.index-confirm strong { + font-size: 13px; +} +.index-confirm p { + margin: 5px 0 0; + color: var(--muted); + font-size: 11px; +} +.index-confirm > div:last-child { + display: flex; + gap: 8px; + flex: none; +} +.index-confirm button { + border: 1px solid var(--line); + background: var(--surface); +} +.index-confirm .danger-action { + color: #fff; + border-color: var(--danger); + background: var(--danger); +} +.settings-success { + margin-top: 16px; + padding: 10px 12px; + display: flex; + align-items: center; + gap: 8px; + border-radius: 9px; + color: #2f8c52; + background: color-mix(in srgb, #48a868 12%, transparent); + font-size: 12px; +} +.config-json { + max-height: 440px; + margin: 0; + padding: 20px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 15px; + color: var(--ink); + background: var(--code-bg); + font: 11px/1.65 var(--mono); + white-space: pre; +} +.version-card { + padding: 0 20px; +} +.version-card > div { + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.version-card > div + div { + border-top: 1px solid var(--line); +} +.version-card strong { + font-size: 24px; +} +.version-card code { + max-width: 70%; + overflow: hidden; + color: var(--muted); + font: 11px var(--mono); + text-overflow: ellipsis; + white-space: nowrap; +} +@media (prefers-reduced-motion: reduce) { + .settings-overlay, + .settings-window, + .settings-trigger { + transition: none; + } +} +@media (max-width: 700px) { + .settings-overlay { + padding: 10px; + } + .settings-window { + height: 100%; + border-radius: 15px; + } + .settings-header { + height: 70px; + padding: 0 16px; + } + .settings-header p { + display: none; + } + .settings-layout { + grid-template-columns: 76px minmax(0, 1fr); + } + .settings-sidebar { + padding: 12px 8px; + } + .settings-sidebar button { + height: 52px; + padding: 0; + flex-direction: column; + justify-content: center; + gap: 3px; + font-size: 9px; + } + .settings-sidebar button > span { + width: 22px; + height: 22px; + } + .settings-content { + padding: 24px 16px 36px; + } + .settings-refresh { + top: 14px; + right: 14px; + } + .settings-page-title { + min-height: 82px; + padding-right: 64px; + gap: 12px; + } + .settings-page-icon { + width: 48px; + height: 48px; + } + .status-metrics { + grid-template-columns: 1fr; + } + .index-confirm { + align-items: flex-start; + flex-direction: column; + } + .index-confirm > div:last-child { + align-self: flex-end; + } +} diff --git a/website/app/i18n.ts b/website/app/i18n.ts new file mode 100644 index 00000000..9407fddf --- /dev/null +++ b/website/app/i18n.ts @@ -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 { + 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((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) => + translate(language, key, values), + }; +} diff --git a/website/app/layout.tsx b/website/app/layout.tsx new file mode 100644 index 00000000..0d17ed64 --- /dev/null +++ b/website/app/layout.tsx @@ -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 { + 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 ( + + +