feat(studio): align branding with documentation site (#546)
* feat(studio): align branding with documentation site * fix(studio): refine brand lockups * fix(studio): simplify welcome branding * docs(studio): add comprehensive visual guide * docs: promote Studio to top-level navigation * fix(studio): address review feedback
1
.github/workflows/ci-docs.yml
vendored
|
|
@ -15,6 +15,7 @@ on:
|
|||
- 'integrations/claude_code/README.md'
|
||||
- 'integrations/hermes_agent/README.md'
|
||||
- 'reme_studio/README*.md'
|
||||
- 'reme_studio/figures/**'
|
||||
- 'reme_studio/public/og.jpg'
|
||||
- 'integrations/dsh/README*.md'
|
||||
- 'integrations/dsh/figures/**'
|
||||
|
|
|
|||
1
.github/workflows/deploy-docs.yml
vendored
|
|
@ -12,6 +12,7 @@ on:
|
|||
- "README.md"
|
||||
- "README_ZH.md"
|
||||
- "reme_studio/README*.md"
|
||||
- "reme_studio/figures/**"
|
||||
- "reme_studio/public/og.jpg"
|
||||
- "integrations/dsh/README*.md"
|
||||
- "integrations/dsh/figures/**"
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ function nav(language: "zh" | "en"): DefaultTheme.NavItem[] {
|
|||
return [
|
||||
{ text: zh ? "首页" : "Home", link: `/${language}/` },
|
||||
{ text: zh ? "文档" : "Docs", link: `/${language}/quick_start` },
|
||||
{ text: "Studio", link: `/${language}/workspace/studio` },
|
||||
{ text: zh ? "集成" : "Integrations", link: `/${language}/integrations` },
|
||||
{ text: zh ? "插件" : "Plugins", link: `/${language}/plugin_management` },
|
||||
{ text: zh ? "评测" : "Benchmarks", link: `/${language}/benchmarks/longmemeval` },
|
||||
|
|
@ -185,25 +186,18 @@ function integrationsSidebar(language: "zh" | "en"): DefaultTheme.SidebarItem[]
|
|||
|
||||
function pluginsSidebar(language: "zh" | "en"): DefaultTheme.SidebarItem[] {
|
||||
const zh = language === "zh";
|
||||
return [
|
||||
{
|
||||
text: zh ? "工作区" : "Workspace",
|
||||
collapsed: false,
|
||||
items: [{ text: "ReMe Studio", link: `/${language}/workspace/studio` }],
|
||||
},
|
||||
{
|
||||
text: zh ? "插件" : "Plugins",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: zh ? "插件管理" : "Plugin Management", link: `/${language}/plugin_management` },
|
||||
{ text: zh ? "插件开发" : "Plugin Development", link: `/${language}/plugin_development` },
|
||||
{ text: zh ? "每日论文" : "Daily Paper", link: `/${language}/plugins/daily-paper` },
|
||||
{ text: "Auto Fin", link: `/${language}/plugins/auto-fin` },
|
||||
{ text: "LME", link: `/${language}/plugins/lme` },
|
||||
{ text: "BEAM", link: `/${language}/plugins/beam` },
|
||||
],
|
||||
},
|
||||
];
|
||||
return [{
|
||||
text: zh ? "插件" : "Plugins",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: zh ? "插件管理" : "Plugin Management", link: `/${language}/plugin_management` },
|
||||
{ text: zh ? "插件开发" : "Plugin Development", link: `/${language}/plugin_development` },
|
||||
{ text: zh ? "每日论文" : "Daily Paper", link: `/${language}/plugins/daily-paper` },
|
||||
{ text: "Auto Fin", link: `/${language}/plugins/auto-fin` },
|
||||
{ text: "LME", link: `/${language}/plugins/lme` },
|
||||
{ text: "BEAM", link: `/${language}/plugins/beam` },
|
||||
],
|
||||
}];
|
||||
}
|
||||
|
||||
function benchmarksSidebar(language: "zh" | "en"): DefaultTheme.SidebarItem[] {
|
||||
|
|
@ -235,7 +229,7 @@ function singlePageSidebar(language: "zh" | "en", page: "blog" | "faq"): Default
|
|||
function sidebars(language: "zh" | "en"): DefaultTheme.SidebarMulti {
|
||||
return {
|
||||
[`/${language}/integrations`]: integrationsSidebar(language),
|
||||
[`/${language}/workspace/`]: pluginsSidebar(language),
|
||||
[`/${language}/workspace/`]: [],
|
||||
[`/${language}/plugins/`]: pluginsSidebar(language),
|
||||
[`/${language}/plugin_management`]: pluginsSidebar(language),
|
||||
[`/${language}/plugin_development`]: pluginsSidebar(language),
|
||||
|
|
|
|||
|
|
@ -64,6 +64,18 @@ const externalDocumentRewrites = {
|
|||
"integrations/openclaw/README_ZH.md": [
|
||||
["(./README.md)", "(/en/integrations/openclaw)"],
|
||||
],
|
||||
"reme_studio/README.md": [
|
||||
["(./README_ZH.md)", "(/zh/workspace/studio)"],
|
||||
["(./figures/", "(/figures/studio/"],
|
||||
["(https://github.com/agentscope-ai/ReMe/blob/main/reme_studio/README_ZH.md)", "(/zh/workspace/studio)"],
|
||||
["(https://raw.githubusercontent.com/agentscope-ai/ReMe/main/reme_studio/figures/", "(/figures/studio/"],
|
||||
],
|
||||
"reme_studio/README_ZH.md": [
|
||||
["(./README.md)", "(/en/workspace/studio)"],
|
||||
["(./figures/", "(/figures/studio/"],
|
||||
["(https://github.com/agentscope-ai/ReMe/blob/main/reme_studio/README.md)", "(/en/workspace/studio)"],
|
||||
["(https://raw.githubusercontent.com/agentscope-ai/ReMe/main/reme_studio/figures/", "(/figures/studio/"],
|
||||
],
|
||||
};
|
||||
|
||||
const externalDocumentPreambles = {
|
||||
|
|
@ -220,6 +232,9 @@ for (const [destination, source] of externalDocuments) {
|
|||
await cp(path.join(repoDir, "integrations/dsh/figures"), path.join(outputDir, "public/figures/dsh"), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(path.join(repoDir, "reme_studio/figures"), path.join(outputDir, "public/figures/studio"), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
for (const language of ["zh", "en"]) {
|
||||
await cp(
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ const requiredFiles = [
|
|||
"en/configuration.html",
|
||||
"zh/services.html",
|
||||
"en/services.html",
|
||||
"zh/workspace/studio.html",
|
||||
"en/workspace/studio.html",
|
||||
"zh/reference/jobs.html",
|
||||
"en/reference/jobs.html",
|
||||
"zh/configuration/llms.txt",
|
||||
|
|
@ -70,6 +72,11 @@ assert.match(ChineseHomepage, /用真实评测/);
|
|||
assert.match(ChineseHomepage, /89\.4%/);
|
||||
assert.match(ChineseHomepage, /公开、透明的访问趋势/);
|
||||
|
||||
const ChineseStudio = await readFile(path.join(outputDir, "zh/workspace/studio.html"), "utf8");
|
||||
assert.match(ChineseStudio, /class="VPLink link VPNavBarMenuLink active" href="\/zh\/workspace\/studio"/);
|
||||
assert.match(ChineseStudio, /<span[^>]*>Studio<\/span>/);
|
||||
assert.doesNotMatch(ChineseStudio, /<aside[^>]*class="VPSidebar/);
|
||||
|
||||
const ChineseTraffic = await readFile(path.join(outputDir, "zh/traffic.html"), "utf8");
|
||||
assert.match(ChineseTraffic, /S1OZK1PSDLEpyiU5/);
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ test("generates every required bilingual guide", async () => {
|
|||
await access(path.join(generatedDir, "zh/integrations/openclaw.md"));
|
||||
await access(path.join(generatedDir, "en/integrations/openclaw.md"));
|
||||
await access(path.join(generatedDir, "public/figures/dsh/reme-status-overview.png"));
|
||||
await access(path.join(generatedDir, "public/figures/studio/studio-overview.png"));
|
||||
});
|
||||
|
||||
test("maps mirrored pages back to their canonical repository sources", async () => {
|
||||
|
|
@ -69,6 +70,26 @@ test("publishes portable and accurate DSH instructions", async () => {
|
|||
assert.match(chinese, /每 5 秒仅刷新 DSH 插件的运行时计数/);
|
||||
});
|
||||
|
||||
test("publishes Studio screenshots with site-safe links", async () => {
|
||||
const english = await readFile(path.join(generatedDir, "en/workspace/studio.md"), "utf8");
|
||||
const chinese = await readFile(path.join(generatedDir, "zh/workspace/studio.md"), "utf8");
|
||||
assert.match(english, /\(\/figures\/studio\/studio-overview\.png\)/);
|
||||
assert.match(chinese, /\(\/figures\/studio\/settings-status\.png\)/);
|
||||
assert.match(english, /\(\/zh\/workspace\/studio\)/);
|
||||
assert.match(chinese, /\(\/en\/workspace\/studio\)/);
|
||||
});
|
||||
|
||||
test("keeps Studio source READMEs portable for package registries", async () => {
|
||||
for (const name of ["README.md", "README_ZH.md"]) {
|
||||
const source = await readFile(path.join(repoDir, "reme_studio", name), "utf8");
|
||||
assert.doesNotMatch(source, /\]\(\.\/figures\//);
|
||||
assert.match(
|
||||
source,
|
||||
/\]\(https:\/\/raw\.githubusercontent\.com\/agentscope-ai\/ReMe\/main\/reme_studio\/figures\//,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("generates the callable Job reference from default.yaml", async () => {
|
||||
const config = parseYaml(await readFile(path.join(repoDir, "reme/config/default.yaml"), "utf8"));
|
||||
const callableJobs = Object.entries(config.jobs)
|
||||
|
|
@ -114,6 +135,8 @@ test("tracks every generated input in documentation CI and deployment", async ()
|
|||
"integrations/dsh/README*.md",
|
||||
"integrations/dsh/figures/**",
|
||||
"integrations/openclaw/README*.md",
|
||||
"reme_studio/README*.md",
|
||||
"reme_studio/figures/**",
|
||||
"benchmark/toolmemory/gitcha.png",
|
||||
];
|
||||
for (const workflow of ["ci-docs.yml", "deploy-docs.yml"]) {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,60 @@
|
|||
|
||||
English | [简体中文](https://github.com/agentscope-ai/ReMe/blob/main/reme_studio/README_ZH.md)
|
||||
|
||||
ReMe Studio is the local web workspace for ReMe. It lets you browse and edit user-owned workspace files, explore memory
|
||||
links, and chat with the ReMe Agent without moving durable memory into a separate application database. Search indexes,
|
||||
graphs, and other derived metadata remain rebuildable from the source files.
|
||||
ReMe Studio is the local web workspace for [ReMe](https://github.com/agentscope-ai/ReMe). It provides one place to
|
||||
browse and edit user-owned memory files, inspect the relationships between them, talk to the ReMe Agent, and understand
|
||||
the health of the local service.
|
||||
|
||||

|
||||
Studio follows ReMe's local-first model: Markdown and other workspace files remain the durable source of truth. Search
|
||||
indexes, catalogs, graphs, caches, and runtime metadata stay derived and rebuildable.
|
||||
|
||||
## Installation
|
||||

|
||||
|
||||
> All screenshots in this README were captured from a real local ReMe service with the Studio UI set to English. The
|
||||
> isolated `Project Aurora` workspace is fictional and contains no user data, credentials, or private endpoints.
|
||||
|
||||
## What Studio provides
|
||||
|
||||
| Area | Capabilities |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Workspace | Browse the complete workspace, focused daily notes, or durable knowledge; refresh automatically as files change |
|
||||
| Markdown | Preview front matter and GitHub Flavored Markdown, edit with Monaco, save safely, download, and keep multiple tabs open |
|
||||
| Memory graph | Explore indexed wikilinks by knowledge category, follow link direction, zoom, fit, and open source notes |
|
||||
| Agent chat | Stream answers, reasoning, tool activity, approvals, data blocks, and usage; reference workspace files by dragging them into chat |
|
||||
| Service center | Inspect process and component health, rebuild derived indexes, review the redacted effective configuration, and verify versions |
|
||||
| Personalization | Switch between English and Chinese and select light, dark, or system appearance |
|
||||
|
||||
## How it fits together
|
||||
|
||||
```text
|
||||
Your local workspace files
|
||||
│
|
||||
▼
|
||||
ReMe HTTP service
|
||||
├── file operations
|
||||
├── derived indexes
|
||||
├── wikilink graph
|
||||
└── read-only Agent chat
|
||||
│
|
||||
▼
|
||||
ReMe Studio
|
||||
```
|
||||
|
||||
Studio is a client of the ReMe HTTP service. It does not replace the workspace with a separate document database and it
|
||||
does not make browser-local state the durable source of truth. Tabs and interface preferences are conveniences; the
|
||||
files in the configured ReMe workspace remain authoritative.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11 or newer with ReMe installed.
|
||||
- A running ReMe HTTP service.
|
||||
- A working Agent and model configuration only if Agent chat is used.
|
||||
- Node.js 22.13 or newer only when developing or building Studio from source.
|
||||
|
||||
See the [ReMe repository README](https://github.com/agentscope-ai/ReMe#readme) for backend installation, workspace, and
|
||||
model configuration.
|
||||
|
||||
## Install
|
||||
|
||||
Install Studio together with ReMe's optional integrations:
|
||||
|
||||
|
|
@ -16,40 +63,221 @@ Install Studio together with ReMe's optional integrations:
|
|||
pip install "reme-ai[core]"
|
||||
```
|
||||
|
||||
For Studio without the other optional integrations, use `pip install "reme-ai[web]"`. The base `reme-ai` package is
|
||||
headless and does not include the frontend assets.
|
||||
Install only the web extra when the other optional integrations are unnecessary:
|
||||
|
||||
The same prebuilt static workspace is available for Node.js applications:
|
||||
```bash
|
||||
pip install "reme-ai[web]"
|
||||
```
|
||||
|
||||
The base `reme-ai` package is headless and does not include the frontend assets.
|
||||
|
||||
Node.js applications can install the same prebuilt static workspace:
|
||||
|
||||
```bash
|
||||
npm install @agentscope-ai/reme_studio
|
||||
```
|
||||
|
||||
Its static entry point is installed at `@agentscope-ai/reme_studio/dist-static/index.html`.
|
||||
The static entry point is installed at `@agentscope-ai/reme_studio/dist-static/index.html`.
|
||||
|
||||
## Features
|
||||
## Start Studio
|
||||
|
||||
- **Workspace browsing**: browse the full workspace or focus on journal and knowledge files through dedicated views. The
|
||||
navigator refreshes as files change on disk.
|
||||
- **Markdown editing and preview**: open multiple files in tabs, render Markdown front matter and GitHub Flavored
|
||||
Markdown, edit with Monaco, save with optimistic modification-time checks, and download files locally.
|
||||
- **Memory graph**: inspect indexed wikilinks under the `wiki`, `personal`, and
|
||||
`procedure` knowledge roots, follow inbound and outbound links, and open the corresponding Markdown source.
|
||||
- **Agent chat**: stream conversations with the read-only workspace Agent, see tool calls and token usage, and drag
|
||||
workspace files into the conversation as references.
|
||||
- **Service management**: inspect service and component memory usage, review the effective redacted configuration and
|
||||
version, and rebuild derived indexes without modifying source memory files.
|
||||
- **Personalization**: switch between English and Chinese, and use light, dark, or system appearance.
|
||||
Start the ReMe HTTP service:
|
||||
|
||||
## Requirements
|
||||
```bash
|
||||
reme start
|
||||
```
|
||||
|
||||
- Python 3.11 or newer with ReMe installed.
|
||||
- A running ReMe HTTP service. Agent chat additionally requires a working Agent and model configuration.
|
||||
- Node.js 22.13 or newer is required only when developing or building Studio from source.
|
||||
Then open <http://127.0.0.1:2333>. The default HTTP service serves both the API and the packaged Studio frontend.
|
||||
|
||||
See the [repository README](https://github.com/agentscope-ai/ReMe#readme) for ReMe installation and backend configuration.
|
||||
To use a different workspace or port, pass normal ReMe configuration overrides:
|
||||
|
||||
## Development
|
||||
```bash
|
||||
reme start workspace_dir=/absolute/path/to/workspace service.port=8000
|
||||
```
|
||||
|
||||
Studio displays the connected endpoint at the bottom of the navigator. A green indicator means the browser can reach
|
||||
the service. If Studio is hosted separately, configure its API URL as described in
|
||||
[Frontend configuration](#frontend-configuration).
|
||||
|
||||
## Interface tour
|
||||
|
||||
### 1. Workspace navigator
|
||||
|
||||
The left navigator exposes four primary entry points:
|
||||
|
||||
- **Files** shows supported files across the entire ReMe workspace.
|
||||
- **Daily** focuses on files under the configured daily-memory directory.
|
||||
- **Knowledge** focuses on the `wiki`, `personal`, and `procedure` directories under durable digest memory.
|
||||
- **Chat** opens a fresh Agent conversation without closing files or graphs that are already open.
|
||||
|
||||
Directories can be expanded independently. The navigator hides dotfiles and dot-directories, orders the newest files
|
||||
first, applies a bounded result limit, and refreshes when files change on disk. The divider can be dragged to resize the
|
||||
navigator, and the menu control in the top bar can collapse it.
|
||||
|
||||

|
||||
|
||||
The tab strip keeps files, graphs, and conversations together. Closing a tab does not delete its file. When an edited
|
||||
file has unsaved changes, Studio protects it from an accidental close and also supports closing the current tab or the
|
||||
other tabs from the tab context menu.
|
||||
|
||||
### 2. Daily notes
|
||||
|
||||
The Daily view gathers both day-index pages and dated note directories without changing their on-disk paths. It is a
|
||||
focused view of the same local workspace, so a note opened here can also appear in Files and remains available to ReMe's
|
||||
indexing and Agent workflows.
|
||||
|
||||

|
||||
|
||||
Use Daily to review chronological memory, open generated resource notes, and move between recent work without browsing
|
||||
the rest of the workspace tree.
|
||||
|
||||
### 3. Markdown preview
|
||||
|
||||
Preview mode renders:
|
||||
|
||||
- YAML front matter in a dedicated summary block.
|
||||
- Headings, lists, links, code, tables, task lists, and other GitHub Flavored Markdown.
|
||||
- The original workspace-relative path above the document.
|
||||
|
||||
The download control saves a local copy through the browser. Preview mode never modifies the source file.
|
||||
|
||||

|
||||
|
||||
### 4. Markdown editing and safe saves
|
||||
|
||||
Edit mode uses Monaco with Markdown syntax highlighting, line numbers, keyboard navigation, and a full-height editing
|
||||
surface. Switch between **Preview** and **Edit** at any time.
|
||||
|
||||

|
||||
|
||||
Studio records the file modification time when it loads a document and sends that value back on save. If another
|
||||
process changed the file in the meantime, the ReMe service rejects the stale write instead of silently overwriting the
|
||||
newer content. After a successful save, Studio refreshes the file state and clears the unsaved marker.
|
||||
|
||||
### 5. Knowledge and memory graphs
|
||||
|
||||
Knowledge groups durable memory into three conventional roots:
|
||||
|
||||
- `wiki` for entities, projects, topics, and stable knowledge.
|
||||
- `personal` for user-specific preferences and long-lived context.
|
||||
- `procedure` for repeatable workflows and operating knowledge.
|
||||
|
||||
Each category has a **Graph** action. The graph is generated from ReMe's indexed wikilinks and preserves their original
|
||||
source-to-target direction.
|
||||
|
||||

|
||||
|
||||
The graph view provides:
|
||||
|
||||
- Indexed-file nodes, category roots, and directed wikilink edges.
|
||||
- Node and link counts for the selected category.
|
||||
- Zoom in, zoom out, and fit-to-view controls.
|
||||
- A legend for file nodes and link direction.
|
||||
- Direct opening of the corresponding Markdown source when a file node is selected.
|
||||
|
||||
The graph is derived state. If it is empty, first allow ReMe to ingest the relevant files; rebuilding search indexes in
|
||||
Settings does not rescan files or reconstruct the wikilink graph.
|
||||
|
||||
### 6. Agent chat
|
||||
|
||||
Chat opens in the same tab workspace, so files and graphs can remain available while a conversation is in progress.
|
||||
|
||||

|
||||
|
||||
The conversation surface supports:
|
||||
|
||||
- Token-by-token streaming from the ReMe Agent.
|
||||
- Separate rendering for answer text, reasoning, tool calls, structured data, approvals, and usage information.
|
||||
- Collapsible completed reasoning and tool blocks to keep long conversations readable.
|
||||
- Multiple independent chat tabs and resumable backend session identifiers.
|
||||
- Starter prompts for common memory questions.
|
||||
- Dragging a workspace file into the composer to insert a path reference.
|
||||
- `Enter` to send and `Shift+Enter` for a new line.
|
||||
|
||||
The built-in chat job is read-only with respect to the workspace. It can search and read context but should not be used
|
||||
as a substitute for explicitly saving durable knowledge. Chat requires a valid Agent/model configuration; file browsing,
|
||||
editing, graphs, and service inspection do not.
|
||||
|
||||
### 7. Service status
|
||||
|
||||
Open **Settings** from the top bar to inspect the backend that Studio is actually using.
|
||||
|
||||

|
||||
|
||||
Status reports:
|
||||
|
||||
- Whether the ReMe service is reachable and the endpoint in use.
|
||||
- The current ReMe backend version.
|
||||
- Process resident memory and total memory estimated for stateful components.
|
||||
- The number of healthy components.
|
||||
- Runtime facts for the file graph, file store, keyword index, and embedding store when configured, including nodes,
|
||||
edges, chunks, documents, vocabulary, dimensions, cache size, and component memory where available.
|
||||
|
||||
Use **Refresh** to request a new snapshot after files are indexed or service configuration changes.
|
||||
|
||||
### 8. Index management
|
||||
|
||||
The Index page provides an explicit maintenance action for derived search state.
|
||||
|
||||

|
||||
|
||||
**Rebuild index** reconstructs the BM25, embedding, and tag indexes from chunks that ReMe has already ingested. Before
|
||||
running, Studio asks for confirmation. This action does not scan the workspace, rechunk files, edit source memory, or
|
||||
rebuild the wikilink graph. Those boundaries preserve ReMe's file-native source of truth.
|
||||
|
||||
### 9. Effective configuration
|
||||
|
||||
Configuration shows the fully resolved application configuration returned by the connected backend.
|
||||
|
||||

|
||||
|
||||
This is useful for confirming the active workspace, directory layout, service options, jobs, and component backends
|
||||
after file configuration and command-line overrides have been merged. Sensitive fields are redacted by ReMe before the
|
||||
configuration reaches the browser.
|
||||
|
||||
### 10. Version and endpoint
|
||||
|
||||
Version presents the ReMe backend version and service endpoint separately from the Studio version shown in the top-left
|
||||
brand lockup.
|
||||
|
||||

|
||||
|
||||
This distinction helps when diagnosing a frontend/backend mismatch: the top bar identifies the installed Studio build,
|
||||
while Settings identifies the connected ReMe service.
|
||||
|
||||
### 11. Language and appearance
|
||||
|
||||
The top bar switches the entire interface between English and Chinese. Appearance supports **Light**, **Dark**, and
|
||||
**System**; System follows the operating-system color preference.
|
||||
|
||||

|
||||
|
||||
The selected language and appearance are remembered in the browser. Both themes use the same ReMe documentation-site
|
||||
brand palette and retain health, selection, focus, and graph contrast.
|
||||
|
||||
## Frontend configuration
|
||||
|
||||
Copy `.env.example` to `.env.local` when persistent local overrides are useful. The vinext/Sites build reads
|
||||
`NEXT_PUBLIC_*`; the FastAPI/static build reads the matching `VITE_*` names.
|
||||
|
||||
| Setting | Build | Purpose |
|
||||
| --------------------------------------- | ------------ | -------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_REME_API_URL` | vinext/Sites | ReMe HTTP service URL; defaults to `http://127.0.0.1:2333` |
|
||||
| `NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS` | vinext/Sites | Comma-separated file extensions shown in the workspace |
|
||||
| `VITE_REME_API_URL` | static | ReMe HTTP service URL; use `/` for same-origin FastAPI hosting |
|
||||
| `VITE_REME_WORKSPACE_EXTENSIONS` | static | Static-build counterpart of the workspace extension list |
|
||||
|
||||
Markdown and text files are visible by default. To include additional text formats:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS=md,txt,mdx
|
||||
VITE_REME_WORKSPACE_EXTENSIONS=md,txt,mdx
|
||||
```
|
||||
|
||||
Only enable formats the browser can safely treat as text. ReMe still enforces workspace containment, allowed paths,
|
||||
encoding checks, size limits, and optimistic modification-time checks on its API boundary.
|
||||
|
||||
## Develop from source
|
||||
|
||||
Start ReMe from the repository root, then run the frontend in another terminal:
|
||||
|
||||
|
|
@ -63,14 +291,14 @@ npm install
|
|||
npm run dev
|
||||
```
|
||||
|
||||
Open <http://localhost:3000>. The frontend connects to
|
||||
`http://127.0.0.1:2333` by default. Override it when needed:
|
||||
Open <http://localhost:3000>. The development 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
|
||||
```
|
||||
|
||||
## ReMe-hosted static build
|
||||
## Build the ReMe-hosted static frontend
|
||||
|
||||
ReMe can serve Studio from the same FastAPI process as its HTTP API. Build the static variant and restart ReMe:
|
||||
|
||||
|
|
@ -82,36 +310,52 @@ cd ..
|
|||
reme start
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:2333>. The static build uses same-origin requests by default. For standalone static development,
|
||||
run `npm run dev:static` and set
|
||||
`VITE_REME_API_URL` to the running ReMe service URL when necessary.
|
||||
|
||||
The regular `npm run build` command remains the vinext/Sites deployment build;
|
||||
`npm run build:static` creates `dist-static/` exclusively for FastAPI and Python package distribution.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `.env.example` to `.env.local` when persistent local overrides are useful. The vinext/Sites build reads
|
||||
`NEXT_PUBLIC_*`; the FastAPI/static build reads the matching `VITE_*` names:
|
||||
|
||||
| Setting | Build | Purpose |
|
||||
| --------------------------------------- | ------------ | -------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_REME_API_URL` | vinext/Sites | ReMe HTTP service URL; defaults to `http://127.0.0.1:2333` |
|
||||
| `NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS` | vinext/Sites | Comma-separated file extensions visible in the workspace |
|
||||
| `VITE_REME_API_URL` | static | ReMe HTTP service URL; use `/` for same-origin FastAPI hosting |
|
||||
| `VITE_REME_WORKSPACE_EXTENSIONS` | static | Static-build counterpart of the workspace extension list |
|
||||
|
||||
The workspace hides dotfiles and dot-directories. It displays Markdown and text files by default. For example:
|
||||
Open <http://127.0.0.1:2333>. The static build uses same-origin requests by default. For standalone static development:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS=md,txt,mdx
|
||||
VITE_REME_WORKSPACE_EXTENSIONS=md,txt,mdx
|
||||
VITE_REME_API_URL=http://127.0.0.1:2333 npm run dev:static
|
||||
```
|
||||
|
||||
The memory graph requires an index built by ReMe. Rebuilding the index from the Studio settings regenerates derived data
|
||||
from workspace files and does not modify the source memory.
|
||||
`npm run build` remains the vinext/Sites deployment build. `npm run build:static` creates `dist-static/` exclusively for
|
||||
FastAPI and Python/npm package distribution. Change frontend source rather than committing generated distribution files.
|
||||
|
||||
## Checks
|
||||
## Troubleshooting
|
||||
|
||||
### Studio says the service is unavailable
|
||||
|
||||
- Confirm `reme start` is still running.
|
||||
- Check the endpoint displayed at the bottom of the navigator.
|
||||
- For a separately hosted frontend, set the correct `NEXT_PUBLIC_REME_API_URL` or `VITE_REME_API_URL` before starting or
|
||||
building it.
|
||||
- Open Settings → Status and use Refresh after correcting the endpoint.
|
||||
|
||||
### Files are missing from the navigator
|
||||
|
||||
- Confirm ReMe was started with the intended `workspace_dir`.
|
||||
- Remember that dotfiles and dot-directories are intentionally hidden.
|
||||
- Check `*_REME_WORKSPACE_EXTENSIONS` when the file is not Markdown or plain text.
|
||||
- Use Files instead of the focused Daily or Knowledge view.
|
||||
|
||||
### The graph is empty or incomplete
|
||||
|
||||
- Confirm the notes are under `digest/wiki`, `digest/personal`, or `digest/procedure`.
|
||||
- Confirm the source files contain wikilinks and have been ingested by ReMe.
|
||||
- Do not expect Settings → Rebuild index to rescan files or rebuild the graph; it only rebuilds search indexes from
|
||||
existing chunks.
|
||||
|
||||
### A Markdown save is rejected
|
||||
|
||||
The file changed after Studio loaded it. Preserve the newer on-disk version, reopen or refresh the file, reapply the
|
||||
intended edit, and save again. This conflict is deliberate protection against silent data loss.
|
||||
|
||||
### Chat does not start
|
||||
|
||||
File features can work while chat is unavailable. Verify the configured Agent wrapper, model, API key, and provider
|
||||
endpoint in the ReMe backend, then check Settings → Status. Do not place credentials in frontend environment variables.
|
||||
|
||||
## Validation
|
||||
|
||||
Run proportionate checks from `reme_studio/`:
|
||||
|
||||
```bash
|
||||
npm run format:check
|
||||
|
|
@ -120,3 +364,6 @@ npm run build
|
|||
npm run build:static
|
||||
npm test
|
||||
```
|
||||
|
||||
The screenshots above also serve as a visual tour of the supported interface, but automated checks remain the source of
|
||||
truth for frontend behavior.
|
||||
|
|
|
|||
|
|
@ -2,21 +2,67 @@
|
|||
|
||||
[English](https://github.com/agentscope-ai/ReMe/blob/main/reme_studio/README.md) | 简体中文
|
||||
|
||||
ReMe Studio 是 ReMe 的本地 Web 工作区。你可以在这里浏览和编辑自己拥有的工作区文件、探索记忆之间的联系,并与 ReMe Agent
|
||||
对话,而无需将持久记忆迁移到独立的应用数据库中。搜索索引、图谱和其他派生元数据均可根据源文件重建。
|
||||
ReMe Studio 是 [ReMe](https://github.com/agentscope-ai/ReMe) 的本地 Web 工作区。你可以在一个界面中浏览和编辑用户拥有的记忆文件、查看文件之间的关系、与 ReMe Agent 对话,并了解本地服务的运行状态。
|
||||
|
||||

|
||||
Studio 遵循 ReMe 的本地优先理念:Markdown 和其他工作区文件始终是持久数据的唯一事实来源;搜索索引、目录、图谱、缓存和运行时元数据都是可重建的派生数据。
|
||||
|
||||

|
||||
|
||||
> 本 README 中的所有截图均来自真实运行的本地 ReMe 服务,Studio 界面统一设置为英文。截图使用隔离的虚构 `Project Aurora` 工作区,不包含用户数据、凭证或私有服务地址。
|
||||
|
||||
## Studio 提供什么
|
||||
|
||||
| 区域 | 能力 |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 工作区 | 浏览完整工作区、聚焦日记或长期知识;磁盘文件变化后自动刷新 |
|
||||
| Markdown | 预览 front matter 和 GitHub Flavored Markdown,使用 Monaco 编辑、安全保存、下载,并同时打开多个标签页 |
|
||||
| 记忆图谱 | 按知识分类查看已索引的 wikilink、识别链接方向、缩放和适应画布,并打开源笔记 |
|
||||
| Agent 对话 | 流式展示回答、推理、工具、审批、结构化数据和用量;可把工作区文件拖入对话作为引用 |
|
||||
| 服务中心 | 查看进程和组件健康状态、重建派生索引、检查脱敏后的生效配置,并核对版本 |
|
||||
| 个性化 | 切换中英文,以及浅色、深色或跟随系统的外观 |
|
||||
|
||||
## 工作方式
|
||||
|
||||
```text
|
||||
用户拥有的本地工作区文件
|
||||
│
|
||||
▼
|
||||
ReMe HTTP 服务
|
||||
├── 文件操作
|
||||
├── 派生索引
|
||||
├── wikilink 图谱
|
||||
└── 只读 Agent 对话
|
||||
│
|
||||
▼
|
||||
ReMe Studio
|
||||
```
|
||||
|
||||
Studio 是 ReMe HTTP 服务的客户端。它不会用独立文档数据库替代工作区,也不会把浏览器中的状态当作持久数据来源。标签页和界面偏好只是使用便利;配置的 ReMe 工作区中的文件始终具有权威性。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.11 或更高版本,并已安装 ReMe。
|
||||
- 正在运行的 ReMe HTTP 服务。
|
||||
- 仅在使用 Agent 对话时需要可用的 Agent 和模型配置。
|
||||
- 仅在从源码开发或构建 Studio 时需要 Node.js 22.13 或更高版本。
|
||||
|
||||
后端安装、工作区和模型配置请参阅 [ReMe 仓库中文 README](https://github.com/agentscope-ai/ReMe/blob/main/README_ZH.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
安装 Studio 和 ReMe 的可选集成功能:
|
||||
安装 Studio 及 ReMe 的可选集成功能:
|
||||
|
||||
```bash
|
||||
pip install "reme-ai[core]"
|
||||
```
|
||||
|
||||
如果只需要 Studio,不需要其他可选集成,可以使用 `pip install "reme-ai[web]"`。基础 `reme-ai` 包以无界面模式分发,
|
||||
不包含前端资源。
|
||||
如果不需要其他可选集成,只安装 Web extra:
|
||||
|
||||
```bash
|
||||
pip install "reme-ai[web]"
|
||||
```
|
||||
|
||||
基础 `reme-ai` 包以无界面模式分发,不包含前端资源。
|
||||
|
||||
Node.js 应用也可以安装同一份预构建静态工作区:
|
||||
|
||||
|
|
@ -26,26 +72,178 @@ npm install @agentscope-ai/reme_studio
|
|||
|
||||
静态入口安装在 `@agentscope-ai/reme_studio/dist-static/index.html`。
|
||||
|
||||
## 功能
|
||||
## 启动 Studio
|
||||
|
||||
- **浏览工作区**:浏览完整工作区,或通过独立视图聚焦日记和知识文件;磁盘中的文件发生变化后,导航器会自动刷新。
|
||||
- **Markdown 编辑与预览**:在多个标签页中打开文件,渲染 Markdown front matter 和 GitHub Flavored Markdown,使用 Monaco
|
||||
编辑器编辑,通过修改时间检查安全保存,并可将文件下载到本地。
|
||||
- **记忆图谱**:查看知识库 `wiki`、`personal` 和 `procedure` 目录中已索引的 wikilink,检查入链和出链,并从图谱打开对应的
|
||||
Markdown 源文件。
|
||||
- **Agent 对话**:与只读工作区 Agent 进行流式对话,查看工具调用和 token 用量,还可将工作区文件拖入对话作为引用。
|
||||
- **服务管理**:查看服务及组件的内存使用情况、当前生效的脱敏配置和版本,并在不修改记忆源文件的情况下重建派生索引。
|
||||
- **个性化设置**:切换中英文界面,并使用浅色、深色或跟随系统的外观。
|
||||
启动 ReMe HTTP 服务:
|
||||
|
||||
## 环境要求
|
||||
```bash
|
||||
reme start
|
||||
```
|
||||
|
||||
- Python 3.11 或更高版本,并已安装 ReMe。
|
||||
- 正在运行的 ReMe HTTP 服务。Agent 对话还需要可用的 Agent 和模型配置。
|
||||
- 只有从源码开发或构建 Studio 时才需要 Node.js 22.13 或更高版本。
|
||||
然后打开 <http://127.0.0.1:2333>。默认 HTTP 服务会同时提供 API 和打包后的 Studio 前端。
|
||||
|
||||
ReMe 的安装和后端配置请参阅[仓库中文 README](https://github.com/agentscope-ai/ReMe/blob/main/README_ZH.md)。
|
||||
如需使用其他工作区或端口,可以传入正常的 ReMe 配置覆盖:
|
||||
|
||||
## 本地开发
|
||||
```bash
|
||||
reme start workspace_dir=/absolute/path/to/workspace service.port=8000
|
||||
```
|
||||
|
||||
Studio 会在导航器底部显示当前连接的服务地址;绿色指示点表示浏览器能够访问该服务。如果 Studio 与后端分开托管,请按[前端配置](#前端配置)设置 API 地址。
|
||||
|
||||
## 界面与能力详解
|
||||
|
||||
### 1. 工作区导航器
|
||||
|
||||
左侧导航器提供四个主要入口:
|
||||
|
||||
- **Files** 显示整个 ReMe 工作区中受支持的文件。
|
||||
- **Daily** 聚焦配置的日记目录。
|
||||
- **Knowledge** 聚焦长期摘要记忆下的 `wiki`、`personal` 和 `procedure` 目录。
|
||||
- **Chat** 新建 Agent 对话,同时保留已经打开的文件和图谱。
|
||||
|
||||
目录可以独立展开。导航器会隐藏点文件和点目录,优先显示最新文件,应用有界的结果数量,并在磁盘文件变化后刷新。可以拖动分隔线调整导航器宽度,也可以使用顶部菜单按钮将其收起。
|
||||
|
||||

|
||||
|
||||
标签栏会把文件、图谱和对话放在同一个工作空间中。关闭标签页不会删除文件。文件存在未保存修改时,Studio 会阻止误关闭;标签页上下文菜单还支持关闭当前标签页或关闭其他标签页。
|
||||
|
||||
### 2. 日记
|
||||
|
||||
Daily 视图会汇总日期索引页面和按日期组织的笔记目录,但不会改变它们在磁盘上的路径。它只是同一本地工作区的聚焦视图,因此在这里打开的笔记也可以出现在 Files 中,并继续参与 ReMe 的索引和 Agent 工作流。
|
||||
|
||||

|
||||
|
||||
可以用 Daily 检查时间顺序记忆、打开资源生成的笔记,并在无需浏览完整工作区树的情况下查看近期工作。
|
||||
|
||||
### 3. Markdown 预览
|
||||
|
||||
预览模式支持:
|
||||
|
||||
- 在独立摘要区展示 YAML front matter。
|
||||
- 渲染标题、列表、链接、代码、表格、任务列表和其他 GitHub Flavored Markdown。
|
||||
- 在文档上方显示原始的工作区相对路径。
|
||||
|
||||
下载按钮会通过浏览器保存文件副本;预览模式不会修改源文件。
|
||||
|
||||

|
||||
|
||||
### 4. Markdown 编辑与安全保存
|
||||
|
||||
编辑模式使用 Monaco,提供 Markdown 语法高亮、行号、键盘导航和占满可用高度的编辑区域。可以随时在 **Preview** 和 **Edit** 之间切换。
|
||||
|
||||

|
||||
|
||||
Studio 会在加载文档时记录文件修改时间,并在保存时把该值发送给后端。如果其他进程已经修改文件,ReMe 服务会拒绝过期写入,而不是静默覆盖更新后的内容。保存成功后,Studio 会刷新文件状态并清除未保存标记。
|
||||
|
||||
### 5. 知识与记忆图谱
|
||||
|
||||
Knowledge 将长期记忆分为三个约定目录:
|
||||
|
||||
- `wiki`:实体、项目、主题和稳定知识。
|
||||
- `personal`:用户偏好和长期个人上下文。
|
||||
- `procedure`:可重复执行的流程和操作知识。
|
||||
|
||||
每个分类都有 **Graph** 操作。图谱由 ReMe 已索引的 wikilink 生成,并保留原始的源到目标方向。
|
||||
|
||||

|
||||
|
||||
图谱视图提供:
|
||||
|
||||
- 已索引文件节点、分类根节点和有向 wikilink 边。
|
||||
- 当前分类的节点数与链接数。
|
||||
- 放大、缩小和适应画布控制。
|
||||
- 文件节点和链接方向图例。
|
||||
- 选择文件节点后直接打开相应 Markdown 源文件。
|
||||
|
||||
图谱属于派生状态。如果图谱为空,请先让 ReMe 摄取相关文件;Settings 中的搜索索引重建不会重新扫描文件,也不会重新构建 wikilink 图谱。
|
||||
|
||||
### 6. Agent 对话
|
||||
|
||||
Chat 会在同一个标签工作区中打开,因此对话进行时仍可保留文件和图谱。
|
||||
|
||||

|
||||
|
||||
对话界面支持:
|
||||
|
||||
- ReMe Agent 逐 token 流式输出。
|
||||
- 分别展示回答文本、推理、工具调用、结构化数据、审批和用量信息。
|
||||
- 自动折叠已经完成的推理和工具块,让长对话更易阅读。
|
||||
- 多个独立对话标签页和可恢复的后端 session id。
|
||||
- 常见记忆问题的起始提示。
|
||||
- 将工作区文件拖入输入框以插入路径引用。
|
||||
- 按 `Enter` 发送,按 `Shift+Enter` 换行。
|
||||
|
||||
内置 chat job 对工作区只读:它可以搜索和读取上下文,但不应替代明确保存长期知识的操作。Agent 对话需要有效的 Agent/模型配置;文件浏览、编辑、图谱和服务检查不需要模型。
|
||||
|
||||
### 7. 服务状态
|
||||
|
||||
从顶部栏打开 **Settings**,可以检查 Studio 实际连接的后端。
|
||||
|
||||

|
||||
|
||||
Status 会显示:
|
||||
|
||||
- ReMe 服务是否可达及当前使用的服务地址。
|
||||
- 当前 ReMe 后端版本。
|
||||
- 进程常驻内存及有状态组件估算的总内存。
|
||||
- 健康组件数量。
|
||||
- 已配置的文件图谱、文件存储、关键词索引和 embedding 存储的运行信息,包括节点、边、chunk、文档、词表、维度、缓存大小和组件内存等可用字段。
|
||||
|
||||
在文件完成索引或服务配置变化后,可以用 **Refresh** 获取新快照。
|
||||
|
||||
### 8. 索引管理
|
||||
|
||||
Index 页面为派生搜索状态提供明确的维护入口。
|
||||
|
||||

|
||||
|
||||
**Rebuild index** 会基于 ReMe 已经摄取的 chunk 重建 BM25、embedding 和 tag 索引。Studio 会在执行前要求确认。该操作不会扫描工作区、重新分块、修改源记忆,也不会重建 wikilink 图谱。这些边界保证 ReMe 的文件仍是唯一事实来源。
|
||||
|
||||
### 9. 生效配置
|
||||
|
||||
Configuration 显示当前后端返回的完整解析后应用配置。
|
||||
|
||||

|
||||
|
||||
在文件配置与命令行覆盖完成合并后,可以在这里确认实际工作区、目录布局、服务选项、jobs 和组件后端。ReMe 会在配置到达浏览器之前对敏感字段进行脱敏。
|
||||
|
||||
### 10. 版本与服务地址
|
||||
|
||||
Version 会显示 ReMe 后端版本和服务地址,并与左上角品牌区域显示的 Studio 版本明确区分。
|
||||
|
||||

|
||||
|
||||
这有助于定位前后端版本不一致的问题:顶部栏标识当前安装的 Studio 构建,Settings 标识当前连接的 ReMe 服务。
|
||||
|
||||
### 11. 语言与外观
|
||||
|
||||
顶部栏可以把整个界面切换为英文或中文。外观支持 **Light**、**Dark** 和 **System**;System 会跟随操作系统的颜色偏好。
|
||||
|
||||

|
||||
|
||||
语言和外观选择会保存在浏览器中。两套主题都复用 ReMe 文档站的品牌色,并为健康状态、选择状态、焦点和图谱保留足够对比度。
|
||||
|
||||
## 前端配置
|
||||
|
||||
如需持久保存本地覆盖,可以将 `.env.example` 复制为 `.env.local`。vinext/Sites 构建读取 `NEXT_PUBLIC_*`,FastAPI/static 构建读取对应的 `VITE_*` 变量。
|
||||
|
||||
| 配置项 | 构建类型 | 作用 |
|
||||
| --------------------------------------- | ------------ | ------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_REME_API_URL` | vinext/Sites | ReMe HTTP 服务地址,默认 `http://127.0.0.1:2333` |
|
||||
| `NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS` | vinext/Sites | 工作区中显示的扩展名,使用逗号分隔 |
|
||||
| `VITE_REME_API_URL` | static | ReMe HTTP 服务地址;FastAPI 同源托管时使用 `/` |
|
||||
| `VITE_REME_WORKSPACE_EXTENSIONS` | static | static 构建使用的工作区扩展名列表 |
|
||||
|
||||
默认显示 Markdown 和文本文件。如需增加其他文本格式:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS=md,txt,mdx
|
||||
VITE_REME_WORKSPACE_EXTENSIONS=md,txt,mdx
|
||||
```
|
||||
|
||||
只应启用浏览器能够安全视为文本的格式。在 API 边界,ReMe 仍会执行工作区范围限制、允许路径、编码检查、大小限制和乐观修改时间检查。
|
||||
|
||||
## 从源码开发
|
||||
|
||||
先在仓库根目录启动 ReMe,然后在另一个终端运行前端:
|
||||
|
||||
|
|
@ -59,15 +257,15 @@ npm install
|
|||
npm run dev
|
||||
```
|
||||
|
||||
打开 <http://localhost:3000>。前端默认连接 `http://127.0.0.1:2333`,需要时可覆盖该地址:
|
||||
打开 <http://localhost:3000>。开发前端默认连接 `http://127.0.0.1:2333`,需要时可覆盖:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:8000 npm run dev
|
||||
```
|
||||
|
||||
## 由 ReMe 托管的静态构建
|
||||
## 构建由 ReMe 托管的静态前端
|
||||
|
||||
ReMe 可以通过提供 HTTP API 的同一个 FastAPI 进程托管 Studio。构建静态版本并重启 ReMe:
|
||||
ReMe 可以使用提供 HTTP API 的同一个 FastAPI 进程托管 Studio。构建静态版本并重启 ReMe:
|
||||
|
||||
```bash
|
||||
cd reme_studio
|
||||
|
|
@ -77,34 +275,47 @@ cd ..
|
|||
reme start
|
||||
```
|
||||
|
||||
打开 <http://127.0.0.1:2333>。静态构建默认使用同源请求。进行独立的静态开发时,运行
|
||||
`npm run dev:static`;如有需要,将 `VITE_REME_API_URL` 设置为正在运行的 ReMe 服务地址。
|
||||
|
||||
常规的 `npm run build` 命令仍用于 vinext/Sites 部署构建;`npm run build:static` 仅为 FastAPI 和 Python 包分发生成
|
||||
`dist-static/`。
|
||||
|
||||
## 配置
|
||||
|
||||
如需持久保存本地覆盖,可以将 `.env.example` 复制为 `.env.local`。vinext/Sites 构建读取
|
||||
`NEXT_PUBLIC_*`,FastAPI/static 构建读取对应的 `VITE_*` 变量:
|
||||
|
||||
| 配置项 | 构建类型 | 作用 |
|
||||
| --------------------------------------- | ------------ | ------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_REME_API_URL` | vinext/Sites | ReMe HTTP 服务地址,默认 `http://127.0.0.1:2333` |
|
||||
| `NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS` | vinext/Sites | 工作区允许显示的扩展名,使用逗号分隔 |
|
||||
| `VITE_REME_API_URL` | static | ReMe HTTP 服务地址;由 FastAPI 同源托管时使用 `/` |
|
||||
| `VITE_REME_WORKSPACE_EXTENSIONS` | static | static 构建使用的工作区扩展名列表 |
|
||||
|
||||
工作区会隐藏点文件和点目录,并且默认只显示 Markdown 和文本文件。例如:
|
||||
打开 <http://127.0.0.1:2333>。静态构建默认使用同源请求。进行独立静态开发时:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS=md,txt,mdx
|
||||
VITE_REME_WORKSPACE_EXTENSIONS=md,txt,mdx
|
||||
VITE_REME_API_URL=http://127.0.0.1:2333 npm run dev:static
|
||||
```
|
||||
|
||||
记忆图谱依赖 ReMe 的摄取流程。在 Studio 设置中重建索引时,只会基于已摄取的 chunks 重建 BM25 和 Embedding 索引;不会扫描工作区文件、重新分块、修改记忆源文件或重建 wikilink 图谱。
|
||||
`npm run build` 仍用于 vinext/Sites 部署构建;`npm run build:static` 仅为 FastAPI 以及 Python/npm 包分发生成 `dist-static/`。应修改前端源文件,而不是提交生成的分发文件。
|
||||
|
||||
## 检查
|
||||
## 常见问题
|
||||
|
||||
### Studio 提示服务不可用
|
||||
|
||||
- 确认 `reme start` 仍在运行。
|
||||
- 检查导航器底部显示的服务地址。
|
||||
- 前后端分开托管时,请在启动或构建前设置正确的 `NEXT_PUBLIC_REME_API_URL` 或 `VITE_REME_API_URL`。
|
||||
- 修正地址后,打开 Settings → Status 并点击 Refresh。
|
||||
|
||||
### 导航器中缺少文件
|
||||
|
||||
- 确认 ReMe 使用预期的 `workspace_dir` 启动。
|
||||
- 点文件和点目录会被刻意隐藏。
|
||||
- 如果不是 Markdown 或纯文本,请检查 `*_REME_WORKSPACE_EXTENSIONS`。
|
||||
- 使用 Files,而不是仅显示部分目录的 Daily 或 Knowledge。
|
||||
|
||||
### 图谱为空或不完整
|
||||
|
||||
- 确认笔记位于 `digest/wiki`、`digest/personal` 或 `digest/procedure`。
|
||||
- 确认源文件包含 wikilink,并已被 ReMe 摄取。
|
||||
- Settings → Rebuild index 只从现有 chunk 重建搜索索引,不会扫描文件或重建图谱。
|
||||
|
||||
### Markdown 保存被拒绝
|
||||
|
||||
文件在 Studio 加载后被其他进程修改。请保留磁盘上的新版本,重新打开或刷新文件,再应用目标修改并保存。这是防止静默数据丢失的预期冲突保护。
|
||||
|
||||
### Chat 无法启动
|
||||
|
||||
文件功能可在 Chat 不可用时正常工作。请检查 ReMe 后端配置的 Agent wrapper、模型、API key 和提供商地址,再查看 Settings → Status。不要把凭证放进前端环境变量。
|
||||
|
||||
## 验证
|
||||
|
||||
在 `reme_studio/` 中运行与改动范围相称的检查:
|
||||
|
||||
```bash
|
||||
npm run format:check
|
||||
|
|
@ -113,3 +324,5 @@ npm run build
|
|||
npm run build:static
|
||||
npm test
|
||||
```
|
||||
|
||||
以上截图也构成受支持界面的可视化导览,但前端行为仍以自动化检查为准。
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: #99918a;
|
||||
color: var(--muted);
|
||||
font: 11px var(--mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
.modeSwitch .modeActive {
|
||||
color: #8e3b12;
|
||||
color: var(--accent-strong);
|
||||
background: var(--soft);
|
||||
}
|
||||
.iconBtn {
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
}
|
||||
.iconBtn:hover {
|
||||
color: var(--accent);
|
||||
border-color: #f2c7ad;
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
|
||||
}
|
||||
.iconBtn:disabled {
|
||||
opacity: 0.42;
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@
|
|||
stroke-width: 3px;
|
||||
}
|
||||
.labels .activeLabel {
|
||||
fill: #fff;
|
||||
fill: var(--accent-foreground);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 7px;
|
||||
font-size: 11px;
|
||||
|
|
@ -298,7 +298,7 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
.openFile:hover {
|
||||
color: #fff;
|
||||
color: var(--accent-foreground);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
|
@ -370,7 +370,7 @@
|
|||
max-height: 45%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(35, 25, 18, 0.18);
|
||||
box-shadow: 0 12px 32px var(--shadow);
|
||||
}
|
||||
.legend {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -3,35 +3,54 @@
|
|||
|
||||
: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;
|
||||
--bg: #ffffff;
|
||||
--panel: #f4f7f5;
|
||||
--surface: #ffffff;
|
||||
--subtle: #f1f6f3;
|
||||
--topbar: #ffffff;
|
||||
--hover: #f1f6f3;
|
||||
--code-bg: #f1f6f3;
|
||||
--line: #dce5e0;
|
||||
--ink: #17221d;
|
||||
--muted: #526159;
|
||||
--accent: #087f6a;
|
||||
--accent-foreground: #ffffff;
|
||||
--accent-strong: #086554;
|
||||
--accent-bright: #19a98f;
|
||||
--brand-blue: #3156d9;
|
||||
--soft: rgba(8, 127, 106, 0.14);
|
||||
--danger: #d8433e;
|
||||
--danger-foreground: #000000;
|
||||
--success: #087f6a;
|
||||
--shadow: rgba(19, 70, 91, 0.14);
|
||||
--shadow-strong: rgba(19, 70, 91, 0.24);
|
||||
--overlay: rgba(9, 32, 25, 0.28);
|
||||
--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;
|
||||
--bg: #0d1512;
|
||||
--panel: #09100d;
|
||||
--surface: #14201b;
|
||||
--subtle: #17251f;
|
||||
--topbar: #0d1512;
|
||||
--hover: #17251f;
|
||||
--code-bg: #17251f;
|
||||
--line: #283a33;
|
||||
--ink: #edf7f3;
|
||||
--muted: #bacbc4;
|
||||
--accent: #57dfc3;
|
||||
--accent-foreground: #0d1512;
|
||||
--accent-strong: #35c6a9;
|
||||
--accent-bright: #57dfc3;
|
||||
--brand-blue: #6575e8;
|
||||
--soft: rgba(87, 223, 195, 0.14);
|
||||
--danger: #f07872;
|
||||
--danger-foreground: #0d1512;
|
||||
--success: #57dfc3;
|
||||
--shadow: rgba(2, 14, 10, 0.34);
|
||||
--shadow-strong: rgba(2, 14, 10, 0.52);
|
||||
--overlay: rgba(2, 14, 10, 0.58);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
|
@ -50,7 +69,12 @@ textarea {
|
|||
}
|
||||
body {
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
background: radial-gradient(
|
||||
circle at 8% 8%,
|
||||
color-mix(in srgb, var(--accent-bright) 5.5%, transparent),
|
||||
transparent 28rem
|
||||
),
|
||||
var(--bg);
|
||||
font-size: 14px;
|
||||
}
|
||||
button {
|
||||
|
|
@ -66,7 +90,7 @@ textarea:focus-visible {
|
|||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d9d1ca;
|
||||
background: var(--line);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
background-clip: padding-box;
|
||||
|
|
@ -80,7 +104,7 @@ textarea:focus-visible {
|
|||
height: 56px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: 34px 180px 1fr auto;
|
||||
grid-template-columns: 34px 220px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 18px;
|
||||
|
|
@ -91,7 +115,19 @@ textarea:focus-visible {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: block;
|
||||
flex: none;
|
||||
background: url("/reme-icon.svg") center / contain no-repeat;
|
||||
}
|
||||
.brand-title {
|
||||
margin-left: 8px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.app-version-divider {
|
||||
width: 1px;
|
||||
|
|
@ -258,7 +294,7 @@ textarea:focus-visible {
|
|||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 10px 30px rgba(35, 25, 18, 0.16);
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
}
|
||||
.theme-menu button {
|
||||
width: 100%;
|
||||
|
|
@ -382,9 +418,8 @@ textarea:focus-visible {
|
|||
.agent-logo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
background: var(--soft);
|
||||
border-radius: 10px;
|
||||
background: url("/reme-icon.svg") center / contain no-repeat;
|
||||
}
|
||||
.source-tabs {
|
||||
height: 36px;
|
||||
|
|
@ -422,29 +457,29 @@ textarea:focus-visible {
|
|||
background: var(--hover);
|
||||
}
|
||||
.source-tabs button.active {
|
||||
color: #756ae0;
|
||||
background: color-mix(in srgb, #756ae0 15%, transparent);
|
||||
color: var(--brand-blue);
|
||||
background: color-mix(in srgb, var(--brand-blue) 15%, transparent);
|
||||
}
|
||||
.source-tabs .chat-tab {
|
||||
height: 27px;
|
||||
margin-left: 2px;
|
||||
border: 1px solid #f2c7ad;
|
||||
color: #9a471d;
|
||||
background: #fff7f1;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line));
|
||||
color: var(--accent-strong);
|
||||
background: var(--soft);
|
||||
}
|
||||
.source-tabs .chat-tab:hover {
|
||||
border-color: #e9aa85;
|
||||
color: #843912;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-strong);
|
||||
background: var(--soft);
|
||||
}
|
||||
:root[data-theme="dark"] .source-tabs .chat-tab {
|
||||
border-color: #70452f;
|
||||
color: #f2a170;
|
||||
background: #342319;
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
|
||||
color: var(--accent);
|
||||
background: var(--soft);
|
||||
}
|
||||
:root[data-theme="dark"] .source-tabs .chat-tab:hover {
|
||||
border-color: #a9633d;
|
||||
color: #ffc09a;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.tree {
|
||||
min-height: 0;
|
||||
|
|
@ -508,7 +543,7 @@ textarea:focus-visible {
|
|||
flex: 1;
|
||||
}
|
||||
.tree-directory-row.graph-active {
|
||||
background: color-mix(in srgb, #756ae0 12%, transparent);
|
||||
background: color-mix(in srgb, var(--brand-blue) 12%, transparent);
|
||||
}
|
||||
.tree-graph-button {
|
||||
height: 23px;
|
||||
|
|
@ -527,8 +562,8 @@ textarea:focus-visible {
|
|||
}
|
||||
.tree-graph-button:hover,
|
||||
.tree-directory-row.graph-active .tree-graph-button {
|
||||
color: #756ae0;
|
||||
background: color-mix(in srgb, #756ae0 13%, transparent);
|
||||
color: var(--brand-blue);
|
||||
background: color-mix(in srgb, var(--brand-blue) 13%, transparent);
|
||||
}
|
||||
.side-state {
|
||||
padding: 18px 10px;
|
||||
|
|
@ -566,16 +601,13 @@ textarea:focus-visible {
|
|||
height: 7px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: #48a868;
|
||||
box-shadow: 0 0 0 3px #e1f2e6;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 14%, transparent);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
|
@ -641,7 +673,7 @@ textarea:focus-visible {
|
|||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 10px 30px rgba(35, 25, 18, 0.18);
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
}
|
||||
.tab-context-menu button {
|
||||
width: 100%;
|
||||
|
|
@ -700,6 +732,24 @@ textarea:focus-visible {
|
|||
font-size: 22px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.welcome-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.welcome-wordmark {
|
||||
width: 124px;
|
||||
height: 42px;
|
||||
display: block;
|
||||
flex: none;
|
||||
background: url("/reme_logo.png") center / contain no-repeat;
|
||||
}
|
||||
.welcome-brand > span:last-child {
|
||||
font-size: 40px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
.welcome p,
|
||||
.chat-empty p {
|
||||
margin: 8px 0 22px;
|
||||
|
|
@ -713,13 +763,14 @@ textarea:focus-visible {
|
|||
gap: 8px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
color: var(--accent-foreground);
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.welcome small {
|
||||
margin-top: 30px;
|
||||
color: #a29992;
|
||||
color: var(--muted);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.markdown {
|
||||
max-width: 780px;
|
||||
|
|
@ -827,15 +878,21 @@ textarea:focus-visible {
|
|||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
color: var(--accent-foreground);
|
||||
background: var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
.message.assistant .avatar {
|
||||
background: color-mix(in srgb, var(--accent) 82%, #fff);
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.assistant-logo {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: block;
|
||||
background: url("/reme-icon.svg") center / contain no-repeat;
|
||||
}
|
||||
.message.user .avatar {
|
||||
color: var(--ink);
|
||||
background: var(--hover);
|
||||
|
|
@ -928,12 +985,12 @@ textarea:focus-visible {
|
|||
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));
|
||||
background: color-mix(in srgb, var(--brand-blue) 9%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--brand-blue) 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));
|
||||
background: color-mix(in srgb, var(--success) 8%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--success) 35%, var(--line));
|
||||
}
|
||||
.stream-block.approval {
|
||||
background: color-mix(in srgb, #d49a2c 9%, var(--panel));
|
||||
|
|
@ -976,7 +1033,7 @@ textarea:focus-visible {
|
|||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 28px rgba(35, 25, 18, 0.14);
|
||||
box-shadow: 0 8px 28px var(--shadow);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
|
|
@ -987,7 +1044,7 @@ textarea:focus-visible {
|
|||
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);
|
||||
0 8px 28px var(--shadow);
|
||||
}
|
||||
.composer textarea {
|
||||
min-height: 78px;
|
||||
|
|
@ -1007,8 +1064,8 @@ textarea:focus-visible {
|
|||
flex: none;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--accent) 82%, #fff);
|
||||
color: var(--accent-foreground);
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.composer button:disabled {
|
||||
|
|
@ -1054,7 +1111,7 @@ textarea:focus-visible {
|
|||
}
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
grid-template-columns: 34px 60px 1fr auto;
|
||||
grid-template-columns: 34px max-content minmax(0, 1fr) auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.resource-links,
|
||||
|
|
@ -1071,7 +1128,7 @@ textarea:focus-visible {
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
box-shadow: 16px 0 42px rgba(75, 49, 30, 0.16);
|
||||
box-shadow: 16px 0 42px var(--shadow-strong);
|
||||
}
|
||||
.navigator-resize-handle {
|
||||
display: none;
|
||||
|
|
@ -1080,6 +1137,29 @@ textarea:focus-visible {
|
|||
left: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.topbar {
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.topbar > span {
|
||||
display: none;
|
||||
}
|
||||
.topbar-actions {
|
||||
grid-column: 3;
|
||||
gap: 4px;
|
||||
}
|
||||
.language-switch button {
|
||||
min-width: 27px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.theme-menu summary,
|
||||
.settings-trigger {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
|
|
@ -1090,7 +1170,7 @@ textarea:focus-visible {
|
|||
place-items: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
background: rgba(30, 25, 21, 0.26);
|
||||
background: var(--overlay);
|
||||
backdrop-filter: blur(5px);
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
|
@ -1107,7 +1187,7 @@ textarea:focus-visible {
|
|||
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);
|
||||
box-shadow: 0 28px 80px var(--shadow-strong);
|
||||
transform: translateY(12px) scale(0.985);
|
||||
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
|
@ -1181,12 +1261,8 @@ textarea:focus-visible {
|
|||
background: var(--hover);
|
||||
}
|
||||
.settings-sidebar button.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--accent) 82%, #fff),
|
||||
var(--accent)
|
||||
);
|
||||
color: var(--accent-foreground);
|
||||
background: linear-gradient(135deg, var(--accent-strong), var(--accent));
|
||||
box-shadow: 0 5px 14px color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
}
|
||||
.settings-sidebar button > span {
|
||||
|
|
@ -1277,19 +1353,19 @@ textarea:focus-visible {
|
|||
place-items: center;
|
||||
border-radius: 15px;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(35, 25, 18, 0.14);
|
||||
box-shadow: 0 8px 18px var(--shadow);
|
||||
}
|
||||
.settings-page-icon.status {
|
||||
background: linear-gradient(145deg, #53b97a, #2f9b5b);
|
||||
background: linear-gradient(145deg, #19a98f, #087f6a);
|
||||
}
|
||||
.settings-page-icon.index {
|
||||
background: linear-gradient(145deg, #8b80e8, #7065d1);
|
||||
background: linear-gradient(145deg, #6575e8, #3156d9);
|
||||
}
|
||||
.settings-page-icon.config {
|
||||
background: linear-gradient(145deg, #f29a55, #e7762d);
|
||||
background: linear-gradient(145deg, #e69a42, #c77724);
|
||||
}
|
||||
.settings-page-icon.version {
|
||||
background: linear-gradient(145deg, #6ca7dc, #477fb7);
|
||||
background: linear-gradient(145deg, #25a8dc, #159fc9);
|
||||
}
|
||||
.status-hero {
|
||||
position: relative;
|
||||
|
|
@ -1301,14 +1377,14 @@ textarea:focus-visible {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border: 1px solid color-mix(in srgb, #39a767 24%, var(--line));
|
||||
border: 1px solid color-mix(in srgb, var(--success) 24%, var(--line));
|
||||
border-radius: 17px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, #42a96d 9%, var(--surface)),
|
||||
color-mix(in srgb, var(--success) 9%, var(--surface)),
|
||||
var(--surface) 62%
|
||||
);
|
||||
box-shadow: 0 5px 18px rgba(35, 25, 18, 0.05);
|
||||
box-shadow: 0 5px 18px color-mix(in srgb, var(--shadow) 42%, transparent);
|
||||
}
|
||||
.status-hero::after {
|
||||
position: absolute;
|
||||
|
|
@ -1316,7 +1392,7 @@ textarea:focus-visible {
|
|||
bottom: -92px;
|
||||
width: 210px;
|
||||
height: 210px;
|
||||
border: 1px solid color-mix(in srgb, #42a96d 16%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--success) 16%, transparent);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
}
|
||||
|
|
@ -1364,10 +1440,10 @@ textarea:focus-visible {
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid color-mix(in srgb, #2f9b5b 25%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--success) 25%, transparent);
|
||||
border-radius: 999px;
|
||||
color: #25834a;
|
||||
background: color-mix(in srgb, #42a96d 9%, transparent);
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 9%, transparent);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
|
@ -1381,8 +1457,8 @@ textarea:focus-visible {
|
|||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #32a25e;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, #32a25e 14%, transparent);
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 14%, transparent);
|
||||
}
|
||||
.health-badge.unhealthy,
|
||||
.component-health.unhealthy {
|
||||
|
|
@ -1410,7 +1486,7 @@ textarea:focus-visible {
|
|||
border: 1px solid var(--line);
|
||||
border-radius: 15px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 5px 18px rgba(35, 25, 18, 0.05);
|
||||
box-shadow: 0 5px 18px color-mix(in srgb, var(--shadow) 42%, transparent);
|
||||
}
|
||||
.status-metrics small,
|
||||
.version-card small {
|
||||
|
|
@ -1453,7 +1529,7 @@ textarea:focus-visible {
|
|||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 5px 18px rgba(35, 25, 18, 0.04);
|
||||
box-shadow: 0 5px 18px color-mix(in srgb, var(--shadow) 34%, transparent);
|
||||
}
|
||||
.component-card::before {
|
||||
position: absolute;
|
||||
|
|
@ -1463,7 +1539,7 @@ textarea:focus-visible {
|
|||
content: "";
|
||||
}
|
||||
.component-card.healthy::before {
|
||||
background: #39a767;
|
||||
background: var(--success);
|
||||
}
|
||||
.component-card header {
|
||||
min-height: 70px;
|
||||
|
|
@ -1528,7 +1604,7 @@ textarea:focus-visible {
|
|||
border: 1px solid var(--line);
|
||||
border-radius: 15px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 5px 18px rgba(35, 25, 18, 0.05);
|
||||
box-shadow: 0 5px 18px color-mix(in srgb, var(--shadow) 42%, transparent);
|
||||
}
|
||||
.settings-card h4 {
|
||||
margin: 8px 0 10px;
|
||||
|
|
@ -1571,7 +1647,7 @@ textarea:focus-visible {
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
color: var(--accent-foreground);
|
||||
background: var(--accent);
|
||||
}
|
||||
.primary-action:disabled {
|
||||
|
|
@ -1602,7 +1678,7 @@ textarea:focus-visible {
|
|||
background: var(--surface);
|
||||
}
|
||||
.index-confirm .danger-action {
|
||||
color: #fff;
|
||||
color: var(--danger-foreground);
|
||||
border-color: var(--danger);
|
||||
background: var(--danger);
|
||||
}
|
||||
|
|
@ -1613,8 +1689,8 @@ textarea:focus-visible {
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 9px;
|
||||
color: #2f8c52;
|
||||
background: color-mix(in srgb, #48a868 12%, transparent);
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
font-size: 12px;
|
||||
}
|
||||
.config-json {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
title: "ReMe Studio",
|
||||
description:
|
||||
"Browse, edit, and discuss your local-first ReMe memory workspace.",
|
||||
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
|
||||
icons: { icon: "/reme-icon.svg", shortcut: "/reme-icon.svg" },
|
||||
openGraph: {
|
||||
title: "ReMe Studio",
|
||||
description: "本地优先的 Agent 记忆工作区",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
SunMoon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { getReMeVersion, readWorkspaceFile, streamChat } from "./api";
|
||||
import { readWorkspaceFile, streamChat } from "./api";
|
||||
import { chatStreamError, formatStreamPayloads } from "./chat-stream";
|
||||
import FilesNavigator from "./files-workspace/FilesNavigator";
|
||||
import MemoryGraphView from "./files-workspace/MemoryGraphView";
|
||||
|
|
@ -42,6 +42,7 @@ import {
|
|||
} from "./workspace-drag";
|
||||
import SettingsCenter from "./settings-center";
|
||||
import { hasUnsavedChanges, unsavedTabsClosedBy } from "./tab-close";
|
||||
import packageJson from "../package.json";
|
||||
|
||||
const TabbedEditor = dynamic(() => import("./files-workspace/TabbedEditor"), {
|
||||
ssr: false,
|
||||
|
|
@ -403,9 +404,7 @@ function Chat({ tab }: { tab: Extract<WorkspaceTab, { type: "agent" }> }) {
|
|||
<div className="messages">
|
||||
{!tab.messages.length && (
|
||||
<div className="chat-empty">
|
||||
<div className="agent-logo">
|
||||
<Sparkles size={24} />
|
||||
</div>
|
||||
<div className="agent-logo" aria-hidden="true" />
|
||||
<h1>{t("chatTitle")}</h1>
|
||||
<p>{t("chatDescription")}</p>
|
||||
<div className="suggestions">
|
||||
|
|
@ -425,9 +424,11 @@ function Chat({ tab }: { tab: Extract<WorkspaceTab, { type: "agent" }> }) {
|
|||
{message.role === "user" ? (
|
||||
t("you")
|
||||
) : (
|
||||
<span aria-label="ReMe" title="ReMe">
|
||||
R
|
||||
</span>
|
||||
<span
|
||||
className="assistant-logo"
|
||||
aria-label="ReMe"
|
||||
title="ReMe"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="bubble">
|
||||
|
|
@ -507,7 +508,6 @@ function Workspace() {
|
|||
const [navOpen, setNavOpen] = useState(true);
|
||||
const [navigatorWidth, setNavigatorWidth] = useState(260);
|
||||
const [resizingNavigator, setResizingNavigator] = useState(false);
|
||||
const [version, setVersion] = useState("");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const { tabs, activeTabId, openAgent, hydrateMarkdown, failMarkdown } =
|
||||
useWorkspaceStore();
|
||||
|
|
@ -551,15 +551,6 @@ function Workspace() {
|
|||
useEffect(() => {
|
||||
hydrateTheme();
|
||||
}, [hydrateTheme]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void getReMeVersion()
|
||||
.then((nextVersion) => {
|
||||
if (!controller.signal.aborted) setVersion(nextVersion);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const saved = Number(localStorage.getItem("reme-navigator-width"));
|
||||
if (!Number.isFinite(saved) || saved <= 0) return;
|
||||
|
|
@ -644,13 +635,10 @@ function Workspace() {
|
|||
</span>
|
||||
</button>
|
||||
<strong>
|
||||
ReMe Studio
|
||||
{version && (
|
||||
<>
|
||||
<span className="app-version-divider" aria-hidden="true" />
|
||||
<span className="app-version">v{version}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="brand-icon" aria-hidden="true" />
|
||||
<span className="brand-title">ReMe Studio</span>
|
||||
<span className="app-version-divider" aria-hidden="true" />
|
||||
<span className="app-version">v{packageJson.version}</span>
|
||||
</strong>
|
||||
<span>
|
||||
{active?.type === "markdown"
|
||||
|
|
@ -662,7 +650,7 @@ function Workspace() {
|
|||
<div className="topbar-actions">
|
||||
<nav className="resource-links" aria-label={t("documentation")}>
|
||||
<a
|
||||
href="https://docs.agentscope.io/reme/latest/en/overview"
|
||||
href="https://reme.agentscope.io"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
|
@ -722,8 +710,14 @@ function Workspace() {
|
|||
<div className="content">
|
||||
{!active && (
|
||||
<div className="welcome">
|
||||
<div className="agent-logo">R</div>
|
||||
<h1>ReMe Studio</h1>
|
||||
<h1 className="welcome-brand">
|
||||
<span
|
||||
className="welcome-wordmark"
|
||||
role="img"
|
||||
aria-label="ReMe"
|
||||
/>
|
||||
<span>Studio</span>
|
||||
</h1>
|
||||
<p>{t("welcomeDescription")}</p>
|
||||
<button onClick={openAgent}>
|
||||
<Sparkles size={16} />
|
||||
|
|
|
|||
BIN
reme_studio/figures/agent-chat.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
reme_studio/figures/appearance-dark.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
reme_studio/figures/daily-notes.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
reme_studio/figures/files-workspace.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
reme_studio/figures/markdown-editor.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
reme_studio/figures/memory-graph.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
reme_studio/figures/settings-configuration.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
reme_studio/figures/settings-index.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
reme_studio/figures/settings-status.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
reme_studio/figures/settings-version.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
reme_studio/figures/studio-overview.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
|
|
@ -7,7 +7,7 @@
|
|||
name="description"
|
||||
content="Browse, edit, and discuss your local-first ReMe memory workspace."
|
||||
/>
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="icon" href="/reme-icon.svg" />
|
||||
<title>ReMe Workspace</title>
|
||||
<script>
|
||||
(function () {
|
||||
|
|
|
|||
4
reme_studio/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@agentscope-ai/reme_studio",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentscope-ai/reme_studio",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@agentscope-ai/icons": "^1.0.67",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@agentscope-ai/reme_studio",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "ReMe Studio web workspace and static distribution",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<title>ReMe</title>
|
||||
<rect width="32" height="32" rx="9" fill="#fff0e7"/>
|
||||
<text
|
||||
x="16"
|
||||
y="22.5"
|
||||
fill="#f36b21"
|
||||
font-family="Arial, Helvetica, sans-serif"
|
||||
font-size="20"
|
||||
font-weight="800"
|
||||
text-anchor="middle"
|
||||
>R</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 321 B |
27
reme_studio/public/reme-icon.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ReMe">
|
||||
<defs>
|
||||
<linearGradient id="brand" x1="14" y1="12" x2="51" y2="51" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#21d1bb"/>
|
||||
<stop offset="0.52" stop-color="#159fc9"/>
|
||||
<stop offset="1" stop-color="#3156d9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glass" x1="10" y1="7" x2="56" y2="59" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ffffff" stop-opacity="0.94"/>
|
||||
<stop offset="0.48" stop-color="#e4fffa" stop-opacity="0.82"/>
|
||||
<stop offset="1" stop-color="#dbe7ff" stop-opacity="0.74"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-30%" y="-30%" width="160%" height="170%">
|
||||
<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#287ca6" flood-opacity="0.22"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="3" y="3" width="58" height="58" rx="18" fill="url(#glass)"/>
|
||||
<rect x="3.75" y="3.75" width="56.5" height="56.5" rx="17.25" fill="none" stroke="url(#brand)" stroke-opacity="0.46" stroke-width="1.5"/>
|
||||
<path d="M10 21C18 8 43 7 55 17" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round" opacity="0.9"/>
|
||||
</g>
|
||||
|
||||
<path d="M18 49V15h14.5C41 15 46 19.5 46 27s-5 12-13.5 12H27" fill="none" stroke="url(#brand)" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M27 38.5 46 49" fill="none" stroke="url(#brand)" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="27" cy="38.5" r="2.2" fill="white" stroke="#43d7c5" stroke-width="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
BIN
reme_studio/public/reme_logo.png
Normal file
|
After Width: | Height: | Size: 283 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "reme_studio"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "Optional ReMe Studio static frontend."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
120
reme_studio/tests/brand-theme.test.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("Studio reuses the canonical ReMe brand assets", async () => {
|
||||
const pairs = [
|
||||
["../../docs/figure/reme-icon.svg", "../public/reme-icon.svg"],
|
||||
["../../docs/figure/reme_logo.png", "../public/reme_logo.png"],
|
||||
];
|
||||
|
||||
for (const [canonical, studio] of pairs) {
|
||||
assert.deepEqual(
|
||||
await readFile(new URL(canonical, import.meta.url)),
|
||||
await readFile(new URL(studio, import.meta.url)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Studio theme uses the documentation site's canonical palette", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../app/globals.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
for (const color of [
|
||||
"#087f6a",
|
||||
"#086554",
|
||||
"#19a98f",
|
||||
"#3156d9",
|
||||
"#ffffff",
|
||||
"#f4f7f5",
|
||||
"#17221d",
|
||||
"#57dfc3",
|
||||
"#0d1512",
|
||||
"#edf7f3",
|
||||
]) {
|
||||
assert.match(css, new RegExp(color));
|
||||
}
|
||||
});
|
||||
|
||||
test("solid theme actions use foreground colors with sufficient contrast", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../app/globals.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const graphCss = await readFile(
|
||||
new URL("../app/files-workspace/memory-graph.module.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const tokens = [
|
||||
...css.matchAll(/:root(?:\[data-theme="dark"\])?\s*\{([^}]+)\}/g),
|
||||
].map(([, declarations]) =>
|
||||
Object.fromEntries(
|
||||
[...declarations.matchAll(/--([\w-]+):\s*(#[\da-f]{6});/gi)].map(
|
||||
([, name, value]) => [name, value],
|
||||
),
|
||||
),
|
||||
);
|
||||
const luminance = (color) => {
|
||||
const channels = [1, 3, 5]
|
||||
.map(
|
||||
(offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255,
|
||||
)
|
||||
.map((channel) =>
|
||||
channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4,
|
||||
);
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
};
|
||||
const contrast = (first, second) => {
|
||||
const values = [luminance(first), luminance(second)].sort(
|
||||
(left, right) => right - left,
|
||||
);
|
||||
return (values[0] + 0.05) / (values[1] + 0.05);
|
||||
};
|
||||
|
||||
assert.equal(tokens.length, 2);
|
||||
for (const theme of tokens) {
|
||||
assert.ok(contrast(theme.accent, theme["accent-foreground"]) >= 4.5);
|
||||
assert.ok(contrast(theme.danger, theme["danger-foreground"]) >= 4.5);
|
||||
}
|
||||
for (const selector of [
|
||||
".welcome > button",
|
||||
".composer button",
|
||||
".settings-sidebar button.active",
|
||||
".primary-action",
|
||||
]) {
|
||||
const rule =
|
||||
css.match(
|
||||
new RegExp(`${selector.replaceAll(".", "\\.")} \\{[^}]+\\}`),
|
||||
)?.[0] || "";
|
||||
assert.match(rule, /color: var\(--accent-foreground\);/, selector);
|
||||
}
|
||||
assert.match(graphCss, /fill: var\(--accent-foreground\);/);
|
||||
assert.match(graphCss, /color: var\(--accent-foreground\);/);
|
||||
assert.match(css, /color: var\(--danger-foreground\);/);
|
||||
});
|
||||
|
||||
test("Studio package versions stay aligned", async () => {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
const packageLock = JSON.parse(
|
||||
await readFile(new URL("../package-lock.json", import.meta.url), "utf8"),
|
||||
);
|
||||
const pyproject = await readFile(
|
||||
new URL("../pyproject.toml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(packageJson.version, "0.1.2");
|
||||
assert.equal(packageLock.version, packageJson.version);
|
||||
assert.equal(packageLock.packages[""].version, packageJson.version);
|
||||
assert.match(
|
||||
pyproject,
|
||||
new RegExp(`^version = "${packageJson.version}"$`, "m"),
|
||||
);
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
async function render() {
|
||||
|
|
@ -21,7 +22,14 @@ test("server-renders the ReMe Studio shell", async () => {
|
|||
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||
|
||||
const html = await response.text();
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
assert.match(html, /<title>ReMe Studio<\/title>/i);
|
||||
assert.match(
|
||||
html.replaceAll("<!-- -->", ""),
|
||||
new RegExp(`v${packageJson.version}`),
|
||||
);
|
||||
assert.match(html, /新建 Agent 对话/);
|
||||
assert.match(html, /文件保留在你的本地工作区/);
|
||||
assert.doesNotMatch(
|
||||
|
|
|
|||