Add memory-viewer from silicondawn

- Web UI for browsing and editing OpenClaw memory files
- Cloned from https://github.com/silicondawn/memory-viewer
- Built and running on port 8901
- Features: file tree, markdown rendering, search, live reload
- Full access to MEMORY.md, daily notes, skills, automations
This commit is contained in:
Krilly
2026-02-21 02:28:57 +00:00
parent 690a2e31b3
commit c9acf0c4da
78 changed files with 18899 additions and 1 deletions

Submodule memory-viewer deleted from 1b7f2aa57a

View File

@@ -0,0 +1,16 @@
node_modules
npm-debug.log
.git
.gitignore
README.zh-CN.md
CONTRIBUTING.md
LICENSE
docs
.env
.nyc_output
coverage
.DS_Store
*.log
# Keep CHANGELOG.md - it's imported by src/components/Changelog.tsx
# CHANGELOG.md

View File

@@ -0,0 +1,28 @@
---
name: Bug Report
about: Report a bug to help us improve
title: "[Bug] "
labels: bug
---
## Describe the Bug
A clear and concise description of what the bug is.
## Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. See error
## Expected Behavior
What you expected to happen.
## Actual Behavior
What actually happened.
## Environment
- OS: [e.g., macOS 15, Ubuntu 24.04]
- Node.js version: [e.g., 22.x]
- Browser: [e.g., Chrome 130]
## Screenshots
If applicable, add screenshots.

View File

@@ -0,0 +1,18 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[Feature] "
labels: enhancement
---
## Problem
A clear description of the problem or need. E.g., "I'm always frustrated when..."
## Proposed Solution
Describe the solution you'd like.
## Alternatives Considered
Any alternative solutions or features you've considered.
## Additional Context
Any other context, mockups, or screenshots.

View File

@@ -0,0 +1,123 @@
name: Build and Push Docker Image
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
workflow_dispatch:
inputs:
tag:
description: 'Image tag (default: latest)'
required: false
default: 'latest'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
suffix: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
suffix: arm64
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Tests
run: npm test
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch,suffix=-${{ matrix.suffix }}
type=ref,event=pr,suffix=-${{ matrix.suffix }}
type=semver,pattern={{version}},suffix=-${{ matrix.suffix }}
type=sha,prefix={{branch}}-,suffix=-${{ matrix.suffix }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.suffix }}
cache-to: type=gha,mode=max,scope=${{ matrix.suffix }}
platforms: ${{ matrix.platform }}
manifest:
needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-
type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
- name: Create and push multi-arch manifest
run: |
TAGS="${{ steps.meta.outputs.tags }}"
for TAG in $TAGS; do
echo "Creating manifest for $TAG"
docker manifest create "$TAG" \
"${TAG}-amd64" \
"${TAG}-arm64" || true
docker manifest push "$TAG" || true
done

27
memory-viewer/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules/
dist/
dist-ssr/
*.local
.env
.env.*
server.log
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,46 @@
# Changelog
All notable changes to Memory Viewer will be documented in this file.
## [1.2.0] "Road Trip" - 2026-02-01
### Added
- **PWA support** — Installable as standalone app on mobile, desktop, and Tesla. Offline caching with service worker.
- **Hash-based routing** — URL updates to `#/file/<path>` when opening files. Refresh restores active file, browser back/forward works.
- **Mermaid diagram rendering** — Fenced `mermaid` code blocks render as SVG diagrams with theme-aware styling.
- **Auto-refresh polling** — Files auto-poll every 10s as WebSocket fallback. Manual refresh button in toolbar.
### Changed
- **Express → Hono migration** — Replaced Express + compression + cors with Hono (~14KB). Faster, lighter, better TypeScript support.
- **Monospace font fix** — Code blocks now enforce `var(--font-mono)` for proper CJK alignment in ASCII art.
### Performance
- **Gzip compression** — Server-side compression middleware.
- **CodeMirror lazy loading** — First-paint gzip reduced from 535KB → 310KB.
- **Vendor chunk splitting** — Separate chunks for react, codemirror, markdown, icons.
- **Tesla/large screen optimization** — 24px base font, 56px touch targets, wider sidebar.
### Fixed
- Code blocks without language tag now render properly with `<pre><code>` instead of inline code.
## [1.1.0] - 2026-01-31
### Added
- **Optimistic locking for concurrent edits** — When saving a file that was modified on disk while you were editing, a conflict dialog appears with options to Overwrite, Reload, or Cancel. Prevents accidental data loss when the agent writes to a file during human editing.
- **Changelog page** — In-app changelog accessible from the sidebar footer.
## [1.0.0] - 2026-01-30
### Added
- **Multi-bot remote connections** — Connect to multiple OpenClaw agent workspaces from a single UI. One-click setup via Gateway API bootstrap.
- **Markdown editor** — In-browser editing with Ctrl+S to save.
- **Full-text search** — Search across all memory files instantly (Ctrl+K).
- **System dashboard** — Server uptime, memory usage, load averages, today's memory summary, recent files, and monthly activity chart.
- **Live reload** — Files auto-refresh when changed on disk via WebSocket.
- **Dark/light theme** — Toggle between dark and light themes.
- **Sensitive content masking** — Blur/reveal sensitive text with one click.
- **i18n support** — English and Chinese interface.
- **Syntax highlighting** — Code blocks with Prism.js, copy button, line numbers.
- **File tree sidebar** — Navigate all `.md` files in a collapsible tree with grouping.
- **Responsive design** — Works on mobile with a slide-out sidebar.
- **Bot identity display** — Reads from SOUL.md / IDENTITY.md.

View File

@@ -0,0 +1,52 @@
# Contributing to Memory Viewer
Thanks for your interest in contributing! Here's how to get started.
## Development Setup
1. **Prerequisites:** Node.js >= 18, npm
2. Clone and install:
```bash
git clone https://github.com/silicondawn/memory-viewer.git
cd memory-viewer
npm install
```
3. Start the dev server:
```bash
npm run dev
```
This runs both the API server and Vite dev server concurrently.
## Submitting Pull Requests
1. Fork the repo and create a feature branch from `master`:
```bash
git checkout -b feat/my-feature
```
2. Make your changes and ensure they pass type checking:
```bash
npm run typecheck
```
3. Commit with a clear message following [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` for new features
- `fix:` for bug fixes
- `chore:` for maintenance tasks
- `docs:` for documentation changes
4. Push and open a PR against `master`.
## Code Style
- **TypeScript** — All new code should be written in TypeScript.
- **React** — Functional components with hooks.
- **Tailwind CSS v4** — Use utility classes; avoid custom CSS when possible.
- **Formatting** — Keep lines reasonable (~100 chars). No trailing whitespace.
- Run `npm run typecheck` before committing.
## Reporting Issues
- Use the [Bug Report](https://github.com/silicondawn/memory-viewer/issues/new?template=bug_report.md) or [Feature Request](https://github.com/silicondawn/memory-viewer/issues/new?template=feature_request.md) templates.
- Include steps to reproduce, expected vs actual behavior, and your environment (OS, Node version, browser).
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](./LICENSE).

61
memory-viewer/Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
# Multi-stage build for production
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first for better layer caching
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build frontend
RUN npm run build
# Fix ServerWebSocket type issue by replacing with any type
RUN sed -i 's/import type { ServerWebSocket } from "@hono\/node-ws";/type ServerWebSocket = any;/' server/index.ts
# Compile TypeScript server to JavaScript
RUN npx tsc --project tsconfig.server.json
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy package files and install production dependencies only
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
# Copy built frontend assets
COPY --from=builder /app/dist ./dist
# Copy compiled server JavaScript
COPY --from=builder /app/server-dist ./server-dist
# Create workspace directory for memory files
RUN mkdir -p /app/workspace && chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Expose the default port
EXPOSE 8901
# Set production environment
ENV NODE_ENV=production
ENV PORT=8901
ENV WORKSPACE_DIR=/app/workspace
ENV STATIC_DIR=/app/dist
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "require('http').get('http://localhost:8901/api/info', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
# Start the server using compiled JavaScript
CMD ["node", "server-dist/index.js"]

21
memory-viewer/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Silicon Dawn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

156
memory-viewer/README.md Normal file
View File

@@ -0,0 +1,156 @@
# 📝 Memory Viewer for OpenClaw
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org/)
[![Version](https://img.shields.io/badge/version-1.2.0-orange.svg)](https://github.com/silicondawn/memory-viewer/releases/tag/v1.2.0)
[![OpenClaw](https://img.shields.io/badge/OpenClaw-Compatible-blue)](https://openclaw.com)
A beautiful, dark-themed web UI for browsing and editing OpenClaw AI agent memory files. Built specifically for [OpenClaw](https://openclaw.com) agents that store context in Markdown files.
<p align="center">
<img src="./docs/screenshot-dashboard-dark.png" width="48%" alt="Dashboard (Dark)">
<img src="./docs/screenshot-dashboard-light.png" width="48%" alt="Dashboard (Light)">
</p>
<p align="center">
<img src="./docs/screenshot-viewer-dark.png" width="48%" alt="Viewer (Dark)">
<img src="./docs/screenshot-viewer-light.png" width="48%" alt="Viewer (Light)">
</p>
<p align="center">
<img src="./docs/screenshot-editor-dark.png" width="48%" alt="Editor">
<img src="./docs/screenshot-search-dark.png" width="48%" alt="Search">
</p>
## Why Memory Viewer for OpenClaw?
OpenClaw agents store their memory in Markdown files (`MEMORY.md`, `memory/*.md`). Memory Viewer provides a dedicated web interface to:
- **Browse** memory files in a collapsible tree
- **Search** across all agent memories instantly
- **Edit** files directly in the browser
- **Monitor** agent system status and memory usage
- **Connect** to multiple OpenClaw agents from a single UI
## Features
- **📁 File Tree Sidebar** — Navigate all `.md` files in a collapsible tree
- **📖 Markdown Rendering** — GitHub-flavored Markdown with syntax highlighting, tables, and more
- **✏️ In-Browser Editing** — Edit files directly with Ctrl+S to save, with optimistic locking for conflict detection
- **🔍 Full-Text Search** — Search across all memory files instantly (Ctrl+K)
- **📊 System Dashboard** — Server uptime, memory usage, load averages, and today's memory summary
- **🔄 Live Reload** — Files auto-refresh when changed on disk (via WebSocket), with 10s polling fallback
- **📱 PWA Support** — Installable as a standalone app with offline caching
- **🔗 Deep Linking** — Hash-based routing (`#/file/path`) for bookmarkable file URLs
- **📊 Mermaid Diagrams** — Render flowcharts and diagrams from fenced code blocks
- **🚗 Large Screen Optimized** — Touch-friendly UI for car displays (Tesla) and large screens
- **🌙 Dark/Light Theme** — Toggle between themes, designed for always-on dashboards
- **📱 Responsive** — Works on mobile with a slide-out sidebar
- **🌐 Multi-bot Connections** — Connect to multiple OpenClaw agent workspaces from a single UI
## Quick Start
```bash
# Clone
git clone https://github.com/silicondawn/memory-viewer.git
cd memory-viewer
# Install
npm install
# Run (starts both API server and Vite dev server)
npm run dev
```
Then open http://localhost:5173 in your browser.
## OpenClaw Integration
Memory Viewer works seamlessly with OpenClaw agents. To connect to your OpenClaw agent:
1. Make sure your OpenClaw agent is running and accessible
2. In Memory Viewer, click the network icon in the top-right
3. Add your agent's workspace path (e.g., `/home/user/clawd`)
4. Start browsing and editing your agent's memory files
## Deployment
Memory Viewer can be deployed as a standalone service:
```bash
# Build for production
npm run build
# Start production server
npm start
```
The server runs on port 8901 by default. You can expose it via Cloudflare Tunnel, Nginx, or any reverse proxy.
## Docker Deployment
### Quick Start (Pre-built Image)
Use the pre-built image from GitHub Container Registry:
```bash
# Run directly with docker
docker run -d \
-p 8901:8901 \
-v ~/.openclaw/workspace:/app/workspace:ro \
--name memory-viewer \
ghcr.io/silicondawn/memory-viewer:latest
# Or use docker-compose
docker-compose up -d
```
Open [http://localhost:8901](http://localhost:8901) in your browser.
### Build from Source
```bash
# Clone the repository
git clone https://github.com/silicondawn/memory-viewer.git
cd memory-viewer
# Build and run
docker-compose up -d --build
```
### Docker Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8901` | Container port (fixed in image) |
| `WORKSPACE_DIR` | `/app/workspace` | Directory inside container for `.md` files |
| `STATIC_DIR` | `/app/dist` | Built frontend assets |
### Customizing the Mount Path
Edit `docker-compose.yml` to point to your actual OpenClaw workspace:
```yaml
volumes:
- ~/.openclaw/workspace:/app/workspace:ro
# Windows: C:/Users/YourName/.openclaw/workspace:/app/workspace:ro
```
The `:ro` flag mounts the directory as read-only (recommended for safety).
### Building Custom Images
Use the provided build script:
```bash
# Build with default tag (latest)
./scripts/build-docker.sh
# Build with specific tag
./scripts/build-docker.sh v1.2.0
# Build and push to registry
PUSH=true ./scripts/build-docker.sh v1.2.0
```
## License
MIT © Silicon Dawn

View File

@@ -0,0 +1,92 @@
**中文** | [English](./README.md)
# 📝 Memory Viewer for OpenClaw
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org/)
[![Version](https://img.shields.io/badge/version-1.2.0-orange.svg)](https://github.com/silicondawn/memory-viewer/releases/tag/v1.2.0)
[![OpenClaw](https://img.shields.io/badge/OpenClaw-兼容-blue)](https://openclaw.com)
一个精美的暗色主题 Web UI用于浏览和编辑 OpenClaw AI Agent 的记忆文件。专为使用 Markdown 文件存储上下文的 [OpenClaw](https://openclaw.com) Agent 设计。
<p align="center">
<img src="./docs/screenshot-dashboard-dark.png" width="48%" alt="仪表盘(暗色)">
<img src="./docs/screenshot-dashboard-light.png" width="48%" alt="仪表盘(亮色)">
</p>
<p align="center">
<img src="./docs/screenshot-viewer-dark.png" width="48%" alt="阅读模式(暗色)">
<img src="./docs/screenshot-viewer-light.png" width="48%" alt="阅读模式(亮色)">
</p>
<p align="center">
<img src="./docs/screenshot-editor-dark.png" width="48%" alt="编辑模式">
<img src="./docs/screenshot-search-dark.png" width="48%" alt="搜索面板">
</p>
## 为什么选择 Memory Viewer for OpenClaw
OpenClaw Agent 将记忆存储在 Markdown 文件中(`MEMORY.md``memory/*.md`。Memory Viewer 提供了一个专用的 Web 界面来:
- **浏览** 记忆文件的折叠树形结构
- **搜索** 所有 Agent 记忆,即时查找
- **编辑** 直接在浏览器中修改文件
- **监控** Agent 系统状态和内存使用情况
- **连接** 从单个 UI 连接多个 OpenClaw Agent
## 功能特性
- **📁 文件树侧栏** — 可折叠的树形结构,浏览所有 `.md` 文件
- **📖 Markdown 渲染** — 支持 GFMGitHub 风格 Markdown包括语法高亮、表格等
- **✏️ 浏览器内编辑** — 直接在页面编辑文件Ctrl+S 保存,支持乐观锁冲突检测
- **🔍 全文搜索** — 即时搜索所有记忆文件Ctrl+K
- **📊 系统仪表盘** — 服务器运行时间、内存使用、负载均值、今日记忆摘要
- **🔄 实时刷新** — 文件在磁盘上变更时自动刷新WebSocket + 10 秒轮询兜底)
- **📱 PWA 支持** — 可安装为独立应用,支持离线缓存
- **🔗 深度链接** — 基于 Hash 路由(`#/file/路径`),可收藏和分享文件 URL
- **📊 Mermaid 图表** — 在围栏代码块中渲染流程图和各类图表
- **🚗 大屏优化** — 触控友好的 UI适配车载屏幕Tesla和大型显示器
- **🌙 暗色/亮色主题** — 一键切换,适合常驻仪表盘
- **📱 响应式设计** — 移动端支持侧滑菜单
- **🌐 多 Bot 连接** — 在单个 UI 中连接多个 OpenClaw Agent 工作区
## 快速开始
```bash
# 克隆仓库
git clone https://github.com/silicondawn/memory-viewer.git
cd memory-viewer
# 安装依赖
npm install
# 启动开发服务器(同时启动 API 和 Vite
npm run dev
```
然后在浏览器中打开 http://localhost:5173。
## OpenClaw 集成
Memory Viewer 与 OpenClaw Agent 无缝集成。连接到你的 OpenClaw Agent
1. 确保你的 OpenClaw Agent 正在运行且可访问
2. 在 Memory Viewer 中,点击右上角的网络图标
3. 添加你的 Agent 工作区路径(例如 `/home/user/clawd`
4. 开始浏览和编辑你的 Agent 记忆文件
## 部署
Memory Viewer 可以作为独立服务部署:
```bash
# 构建生产版本
npm run build
# 启动生产服务器
npm start
```
服务器默认运行在 8901 端口。你可以通过 Cloudflare Tunnel、Nginx 或任何反向代理暴露它。
## 许可证
MIT © Silicon Dawn

View File

@@ -0,0 +1,34 @@
services:
memory-viewer:
build:
context: .
dockerfile: Dockerfile
container_name: memory-viewer
ports:
- "8901:8901"
environment:
- NODE_ENV=production
- PORT=8901
- WORKSPACE_DIR=/app/workspace
- STATIC_DIR=/app/dist
volumes:
# Mount your OpenClaw workspace directory here
# Update the path below to match your actual workspace location
- ~/.openclaw/workspace:/app/workspace:ro
# Windows example:
# - C:/Users/YourName/.openclaw/workspace:/app/workspace:ro
# Other Linux/macOS paths:
# - /path/to/your/workspace:/app/workspace:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:8901/api/info', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- memory-viewer-network
networks:
memory-viewer-network:
driver: bridge

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

26
memory-viewer/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📝</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#030712" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
<title>Memory Viewer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
</body>
</html>

8390
memory-viewer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
{
"name": "@silicondawn/memory-viewer",
"version": "1.2.0",
"description": "A beautiful web-based viewer for OpenClaw agent memory files. Browse, search, and edit Markdown files with a dark-themed UI.",
"type": "module",
"license": "MIT",
"author": "Silicon Dawn",
"homepage": "https://github.com/silicondawn/memory-viewer",
"repository": {
"type": "git",
"url": "https://github.com/silicondawn/memory-viewer"
},
"bugs": {
"url": "https://github.com/silicondawn/memory-viewer/issues"
},
"engines": {
"node": ">=18"
},
"keywords": [
"openclaw",
"memory",
"markdown",
"viewer",
"agent"
],
"scripts": {
"dev": "concurrently -n api,web -c blue,green \"npm run server:dev\" \"vite\"",
"build": "vite build",
"start": "node server/dist/index.js",
"server:dev": "tsx watch server/index.ts",
"server:build": "tsx server/index.ts",
"preview": "npm run build && npm start",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.1",
"@codemirror/language-data": "^6.5.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.12",
"@hono/node-server": "^1.19.9",
"@hono/node-ws": "^1.3.0",
"@phosphor-icons/react": "^2.1.10",
"@types/better-sqlite3": "^7.6.13",
"beautiful-mermaid": "^0.1.3",
"better-sqlite3": "^12.6.2",
"chokidar": "^4.0.3",
"gray-matter": "^4.0.3",
"hono": "^4.11.7",
"mermaid": "^11.12.2",
"playwright": "^1.58.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.22.0",
"ws": "^8.18.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.1.1",
"concurrently": "^9.2.1",
"jsdom": "^28.0.0",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vitest": "^4.0.18"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "Memory Viewer",
"short_name": "Memory",
"description": "Browse and edit your AI agent's memory files",
"start_url": "/",
"display": "standalone",
"background_color": "#030712",
"theme_color": "#030712",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1,60 @@
const CACHE_NAME = 'memory-viewer-v1';
const STATIC_ASSETS = ['/'];
// Install: cache shell
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
// Activate: clean old caches
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
// Fetch: network-first for API, cache-first for assets
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
// API calls: network only
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
return;
}
// Assets with hash in filename: cache-first (immutable)
if (url.pathname.startsWith('/assets/') && url.pathname.match(/\-[a-zA-Z0-9_-]{8}\./)) {
e.respondWith(
caches.match(e.request).then((cached) => {
if (cached) return cached;
return fetch(e.request).then((resp) => {
if (resp.ok) {
const clone = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(e.request, clone));
}
return resp;
});
})
);
return;
}
// HTML: network-first, fallback to cache
e.respondWith(
fetch(e.request)
.then((resp) => {
if (resp.ok) {
const clone = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(e.request, clone));
}
return resp;
})
.catch(() => caches.match(e.request))
);
});

View File

@@ -0,0 +1,59 @@
#!/bin/bash
set -e
# Docker Image Build Script for Memory Viewer
# Usage: ./scripts/build-docker.sh [tag]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Default values
REGISTRY="${DOCKER_REGISTRY:-ghcr.io}"
IMAGE_NAME="${DOCKER_IMAGE_NAME:-silicondawn/memory-viewer}"
TAG="${1:-latest}"
FULL_IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
echo "🐳 Building Memory Viewer Docker Image"
echo "======================================"
echo "Registry: $REGISTRY"
echo "Image: $IMAGE_NAME"
echo "Tag: $TAG"
echo "Full: $FULL_IMAGE"
echo ""
# Navigate to project root
cd "$PROJECT_ROOT"
# Build the image
echo "📦 Building Docker image..."
docker build -t "$FULL_IMAGE" .
# Tag additional tags
if [ "$TAG" != "latest" ]; then
docker tag "$FULL_IMAGE" "$REGISTRY/$IMAGE_NAME:latest"
echo "🏷️ Tagged as latest"
fi
echo ""
echo "✅ Build complete!"
echo ""
# Push if requested
if [ "${PUSH:-false}" = "true" ]; then
echo "📤 Pushing to registry..."
docker push "$FULL_IMAGE"
if [ "$TAG" != "latest" ]; then
docker push "$REGISTRY/$IMAGE_NAME:latest"
fi
echo "✅ Push complete!"
else
echo "💡 To push the image, run: PUSH=true $0 $TAG"
echo " Or manually: docker push $FULL_IMAGE"
fi
echo ""
echo "🚀 To run the container:"
echo " docker run -d -p 8901:8901 -v ~/.openclaw/workspace:/app/workspace:ro $FULL_IMAGE"
echo ""
echo " Or use docker-compose:"
echo " docker-compose up -d"

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { app } from './index.js';
describe('Server API', () => {
it('GET /api/info returns bot info', async () => {
// Note: This relies on actual file system unless mocked.
// Ideally we mock fs, but for integration test on CI it's fine.
const res = await app.request('/api/info');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('version');
});
it('GET /api/agent/status returns structure', async () => {
const res = await app.request('/api/agent/status');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('config');
expect(data).toHaveProperty('gateway');
});
});
describe('Backlinks API', () => {
// These tests rely on the actual WORKSPACE directory (~/clawd by default)
// They test the API structure and basic behavior
it('GET /api/resolve-wikilink returns 400 without link', async () => {
const res = await app.request('/api/resolve-wikilink');
expect(res.status).toBe(400);
});
it('GET /api/resolve-wikilink resolves existing file', async () => {
// MEMORY.md should exist in the workspace
const res = await app.request('/api/resolve-wikilink?link=MEMORY');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('found');
expect(data).toHaveProperty('path');
});
it('GET /api/resolve-wikilink returns not found for nonexistent', async () => {
const res = await app.request('/api/resolve-wikilink?link=nonexistent-file-that-does-not-exist-12345');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.found).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { app } from './index.js';
describe('Server API', () => {
it('GET /api/info returns bot info', async () => {
// Note: This relies on actual file system unless mocked.
// Ideally we mock fs, but for integration test on CI it's fine.
const res = await app.request('/api/info');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('version');
});
it('GET /api/agent/status returns structure', async () => {
const res = await app.request('/api/agent/status');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('config');
expect(data).toHaveProperty('gateway');
});
});
describe('Backlinks API', () => {
// These tests rely on the actual WORKSPACE directory (~/clawd by default)
// They test the API structure and basic behavior
it('GET /api/resolve-wikilink returns 400 without link', async () => {
const res = await app.request('/api/resolve-wikilink');
expect(res.status).toBe(400);
});
it('GET /api/resolve-wikilink resolves existing file', async () => {
// MEMORY.md should exist in the workspace
const res = await app.request('/api/resolve-wikilink?link=MEMORY');
expect(res.status).toBe(200);
const data = await res.json() as any;
expect(data).toHaveProperty('found');
expect(data).toHaveProperty('path');
});
it('GET /api/resolve-wikilink returns not found for nonexistent', async () => {
const res = await app.request('/api/resolve-wikilink?link=nonexistent-file-that-does-not-exist-12345');
expect(res.status).toBe(200);
const data = await res.json() as any;
expect(data.found).toBe(false);
});
});

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
// Trigger a cron job via OpenClaw gateway WebSocket
// Usage: node cron-trigger.mjs <jobId>
import { readFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import { randomUUID } from "crypto";
import WebSocket from "ws";
const jobId = process.argv[2];
if (!jobId) { console.log(JSON.stringify({success:false, error:"missing jobId"})); process.exit(1); }
const configPath = join(homedir(), ".openclaw", "openclaw.json");
const config = JSON.parse(readFileSync(configPath, "utf-8"));
const port = config.gateway?.port || 18789;
const token = config.gateway?.auth?.token || "";
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
let connectNonce = null;
let connected = false;
const pending = new Map();
const timeout = setTimeout(() => {
console.log(JSON.stringify({success:false, error:"timeout"}));
ws.close();
process.exit(1);
}, 10000);
function send(obj) { ws.send(JSON.stringify(obj)); }
function request(method, params) {
const id = randomUUID();
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
send({ type: "req", id, method, params });
});
}
ws.on("message", (data) => {
try {
const msg = JSON.parse(data.toString());
// Event frame
if (msg.type === "event") {
if (msg.event === "connect.challenge") {
connectNonce = msg.payload?.nonce;
// Send connect request
request("connect", {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "gateway-client",
displayName: "Memory Viewer",
version: "1.0.0",
platform: "linux",
mode: "backend",
},
caps: [],
auth: { token },
role: "operator",
scopes: ["operator.admin"],
}).then(() => {
connected = true;
// Send cron.run — it's async, so fire and consider it triggered
const cronId = randomUUID();
ws.send(JSON.stringify({type:'req', id:cronId, method:'cron.run', params:{id: jobId}}));
// Give it a moment to dispatch, then report success
setTimeout(() => {
clearTimeout(timeout);
console.log(JSON.stringify({success:true, result:'triggered'}));
ws.close();
process.exit(0);
}, 500);
}).catch((err) => {
clearTimeout(timeout);
console.log(JSON.stringify({success:false, error: err.message}));
ws.close();
process.exit(1);
});
}
return;
}
// Response frame
if (msg.type === "res") {
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
if (msg.ok) p.resolve(msg.payload);
else p.reject(new Error(msg.error?.message || "unknown error"));
}
} catch {}
});
ws.on("error", (err) => {
clearTimeout(timeout);
console.log(JSON.stringify({success:false, error: err.message}));
process.exit(1);
});

File diff suppressed because it is too large Load Diff

624
memory-viewer/src/App.tsx Normal file
View File

@@ -0,0 +1,624 @@
import { useEffect, useState, useCallback } from "react";
import { fetchFiles, fetchSkills, setBaseUrl, getBaseUrl, type FileNode, type SkillInfo } from "./api";
import { FileTree } from "./components/FileTree";
import { FileViewer } from "./components/FileViewer";
import { Dashboard } from "./components/Dashboard";
import { SearchPanel } from "./components/SearchPanel";
import { Connections } from "./components/Connections";
import { Changelog } from "./components/Changelog";
import { SkillsPage } from "./components/SkillsPage";
import { Timeline } from "./components/Timeline";
import { useWebSocket } from "./hooks/useWebSocket";
import { useTheme } from "./hooks/useTheme";
import { useSensitiveState, SensitiveProvider } from "./hooks/useSensitive";
import { useConnections } from "./hooks/useConnections";
import { useAgents } from "./hooks/useAgents";
import { AgentStatusPage } from "./components/AgentStatus";
import { SettingsPage } from "./components/SettingsPage";
import { Tags } from "./components/Tags";
import { CronManager } from "./components/CronManager";
import { BookOpen, X, List, MagnifyingGlass, Sun, Moon, Eye, EyeSlash, Translate, ShareNetwork, CaretDown, CaretUp, ArrowsClockwise, Gear, Monitor, PuzzlePiece, CaretRight, Calendar, SquaresFour, Power, Clock, Tag, Robot, Timer } from "@phosphor-icons/react";
import { useSyncExternalStore } from "react";
import { pluginRegistry } from "./plugins/registry";
import { useZoom } from "./hooks/useZoom";
import { useMarkdownTheme } from "./themes";
import { useResizableSidebar } from "./hooks/useResizableSidebar";
import { useLocaleState, LocaleContext } from "./hooks/useLocale";
export default function App() {
const [files, setFiles] = useState<FileNode[]>([]);
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [activeFile, setActiveFile] = useState("");
const [view, setView] = useState<"dashboard" | "file" | "connections" | "changelog" | "agent-status" | "skills" | "timeline" | "tags" | "cron" | "settings">("dashboard");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [botSelectorOpen, setBotSelectorOpen] = useState(false);
const [agentSelectorOpen, setAgentSelectorOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [quickAccessOpen, setQuickAccessOpen] = useState(() => localStorage.getItem("memory-viewer-quickaccess-open") === "true");
const [teslaMode, setTeslaMode] = useState(() => localStorage.getItem("memory-viewer-tesla") === "true");
const { zoom, setZoom, ZOOM_LEVELS } = useZoom();
const { current: currentMdTheme, setTheme: setMdTheme, themes: mdThemes } = useMarkdownTheme();
const { width: sidebarWidth, onMouseDown: onResizeMouseDown, onTouchStart: onResizeTouchStart } = useResizableSidebar();
const { theme, toggle: toggleTheme } = useTheme();
const sensitive = useSensitiveState();
const localeState = useLocaleState();
const { t, toggleLocale, locale } = localeState;
const connState = useConnections();
const agentsState = useAgents();
const pluginVersion = useSyncExternalStore(pluginRegistry.subscribe, pluginRegistry.getSnapshot);
const allPlugins = pluginRegistry.getAll();
// Sync baseUrl when active connection changes
useEffect(() => {
setBaseUrl(connState.active.url);
}, [connState.active]);
const loadFiles = useCallback(() => {
fetchFiles().then(setFiles).catch(console.error);
fetchSkills().then(setSkills).catch(console.error);
}, []);
// Reload files when active connection or agent changes
useEffect(() => {
loadFiles();
}, [loadFiles, connState.active.id, agentsState.selectedAgentId]);
// Live reload via WebSocket
useWebSocket((data) => {
if (data.type === "file-change") {
loadFiles();
if (data.path === activeFile) {
setRefreshKey((k) => k + 1);
}
}
}, connState.active.url);
// Ctrl+K to open search
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
setSearchOpen(true);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// Close bot selector on outside click
useEffect(() => {
if (!botSelectorOpen) return;
const handler = () => setBotSelectorOpen(false);
setTimeout(() => document.addEventListener("click", handler), 0);
return () => document.removeEventListener("click", handler);
}, [botSelectorOpen]);
// Close agent selector on outside click
useEffect(() => {
if (!agentSelectorOpen) return;
const handler = () => setAgentSelectorOpen(false);
setTimeout(() => document.addEventListener("click", handler), 0);
return () => document.removeEventListener("click", handler);
}, [agentSelectorOpen]);
// Close settings on outside click
useEffect(() => {
if (!settingsOpen) return;
const handler = () => setSettingsOpen(false);
setTimeout(() => document.addEventListener("click", handler), 0);
return () => document.removeEventListener("click", handler);
}, [settingsOpen]);
// Sync hash → state on load and popstate
useEffect(() => {
const readHash = () => {
const hash = window.location.hash;
if (hash.startsWith("#/file/")) {
const path = decodeURIComponent(hash.slice(7));
if (path) {
setActiveFile(path);
setView("file");
return;
}
}
if (hash === "#/agent-status") { setView("agent-status"); return; }
if (hash === "#/connections") { setView("connections"); return; }
if (hash === "#/changelog") { setView("changelog"); return; }
if (hash === "#/skills") { setView("skills"); return; }
if (hash === "#/timeline") { setView("timeline"); return; }
if (hash === "#/tags") { setView("tags"); return; }
if (hash === "#/cron") { setView("cron"); return; }
if (hash === "#/settings") { setView("settings"); return; }
};
readHash();
window.addEventListener("popstate", readHash);
return () => window.removeEventListener("popstate", readHash);
}, []);
const openFile = (path: string) => {
setActiveFile(path);
setView("file");
setSidebarOpen(false);
window.history.pushState(null, "", `#/file/${encodeURIComponent(path)}`);
};
const goHome = () => {
setView("dashboard");
setActiveFile("");
setSidebarOpen(false);
window.history.pushState(null, "", window.location.pathname);
};
const switchBot = (id: string) => {
connState.switchTo(id);
setBotSelectorOpen(false);
setView("dashboard");
setActiveFile("");
};
const switchAgent = (agentId: string) => {
agentsState.selectAgent(agentId);
setAgentSelectorOpen(false);
setView("dashboard");
setActiveFile("");
// Reload files for new agent
setTimeout(() => loadFiles(), 0);
};
const online = connState.statuses[connState.active.id] ?? (connState.active.isLocal ? true : false);
const todayFile = `memory/${new Date().toISOString().slice(0, 10)}.md`;
// Show agent selector only for local connections with multiple agents
const showAgentSelector = connState.active.isLocal && agentsState.agents.length > 1;
return (
<LocaleContext.Provider value={localeState}>
<SensitiveProvider value={sensitive}>
<div className={`flex ${sensitive.hidden ? "" : "sensitive-revealed"} ${teslaMode ? "tesla-mode" : ""}`} style={{ background: "var(--bg-primary)", color: "var(--text-primary)", transform: `scale(${zoom / 100})`, transformOrigin: "top left", width: `${10000 / zoom}%`, height: `${10000 / zoom}vh`, overflow: "hidden" }}>
{/* Mobile overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-30 bg-black/40 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* Sidebar - Redesigned */}
<aside
className={`sidebar fixed z-40 lg:static lg:z-auto inset-y-0 left-0 w-60 border-r flex flex-col shrink-0 transition-transform duration-200 lg:relative ${
sidebarOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
}`}
style={{ width: window.innerWidth >= 1024 ? `${sidebarWidth}px` : undefined }}
>
{/* Header - Minimal */}
<div className="sidebar-header px-3 py-2.5 border-b flex items-center justify-between">
<button onClick={goHome} className="text-sm font-semibold hover:text-blue-400 transition-colors flex items-center gap-1.5" style={{ color: "var(--text-primary)" }}>
<BookOpen className="w-4 h-4" /> Memory Viewer
</button>
<div className="flex items-center gap-0.5">
{/* Mobile close */}
<button onClick={() => setSidebarOpen(false)} className="lg:hidden p-1.5" style={{ color: "var(--text-muted)" }}>
<X className="w-4 h-4" />
</button>
{/* Settings gear - all tools inside */}
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setSettingsOpen(!settingsOpen); }}
className="p-1.5 rounded-md transition-colors hover:bg-white/10"
style={{ color: "var(--text-muted)" }}
title="Settings"
>
<Gear className="w-4 h-4" />
</button>
{settingsOpen && (
<div
className="absolute top-full right-0 mt-2 rounded-lg shadow-xl z-50 p-3 w-56"
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
onClick={(e) => e.stopPropagation()}
>
{/* Quick tools */}
<div className="flex items-center gap-1 mb-3 pb-3 border-b" style={{ borderColor: "var(--border)" }}>
<button onClick={() => window.location.reload()} className="p-2 rounded-md transition-colors hover:bg-white/10 flex-1" style={{ color: "var(--text-muted)" }} title="Refresh">
<ArrowsClockwise className="w-4 h-4 mx-auto" />
</button>
<button onClick={sensitive.toggle} className="p-2 rounded-md transition-colors hover:bg-white/10 flex-1" style={{ color: "var(--text-muted)" }} title={sensitive.hidden ? t("sidebar.showSensitive") : t("sidebar.hideSensitive")}>
{sensitive.hidden ? <EyeSlash className="w-4 h-4 mx-auto" /> : <Eye className="w-4 h-4 mx-auto" />}
</button>
<button onClick={toggleTheme} className="p-2 rounded-md transition-colors hover:bg-white/10 flex-1" style={{ color: "var(--text-muted)" }} title={theme === "dark" ? t("sidebar.lightMode") : t("sidebar.darkMode")}>
{theme === "dark" ? <Sun className="w-4 h-4 mx-auto" /> : <Moon className="w-4 h-4 mx-auto" />}
</button>
<button onClick={toggleLocale} className="p-2 rounded-md transition-colors hover:bg-white/10 flex-1" style={{ color: "var(--text-muted)" }} title={locale === "en" ? "切换到中文" : "Switch to English"}>
<Translate className="w-4 h-4 mx-auto" />
</button>
</div>
{/* Zoom */}
<div className="text-xs font-medium mb-2" style={{ color: "var(--text-muted)" }}>Zoom</div>
<div className="flex flex-wrap gap-1 mb-3">
{ZOOM_LEVELS.map((level) => (
<button
key={level}
onClick={() => setZoom(level)}
className="px-2 py-1 rounded text-xs transition-colors"
style={{
background: zoom === level ? "var(--bg-active)" : "var(--bg-hover)",
color: zoom === level ? "var(--link)" : "var(--text-secondary)",
border: zoom === level ? "1px solid var(--link)" : "1px solid var(--border)",
fontWeight: zoom === level ? 600 : 400,
}}
>
{level}%
</button>
))}
</div>
{/* Preview Theme */}
<div className="text-xs font-medium mb-2" style={{ color: "var(--text-muted)" }}>Preview Theme</div>
<div className="flex flex-wrap gap-1 mb-3">
{mdThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setMdTheme(theme.id)}
className="px-2 py-1 rounded text-xs transition-colors"
style={{
background: currentMdTheme.id === theme.id ? "var(--bg-active)" : "var(--bg-hover)",
color: currentMdTheme.id === theme.id ? "var(--link)" : "var(--text-secondary)",
border: currentMdTheme.id === theme.id ? "1px solid var(--link)" : "1px solid var(--border)",
fontWeight: currentMdTheme.id === theme.id ? 600 : 400,
}}
>
{theme.name}
</button>
))}
</div>
{/* Tesla mode */}
<button
onClick={() => { const v = !teslaMode; setTeslaMode(v); localStorage.setItem("memory-viewer-tesla", String(v)); }}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors"
style={{ background: teslaMode ? "var(--bg-active)" : "var(--bg-hover)", color: teslaMode ? "var(--link)" : "var(--text-secondary)" }}
>
<Monitor className="w-3.5 h-3.5" />
Tesla Mode
{teslaMode && <span className="ml-auto text-[10px]"></span>}
</button>
{/* Plugins */}
{allPlugins.length > 0 && (
<div className="mt-3 pt-3 border-t" style={{ borderColor: "var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wider mb-2 px-2" style={{ color: "var(--text-muted)" }}>
Plugins
</div>
{allPlugins.map((p) => {
const enabled = pluginRegistry.isEnabled(p.id);
return (
<button
key={`${p.id}-${pluginVersion}`}
onClick={() => enabled ? pluginRegistry.disable(p.id) : pluginRegistry.enable(p.id)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors"
style={{ background: enabled ? "var(--bg-active)" : "var(--bg-hover)", color: enabled ? "var(--link)" : "var(--text-secondary)" }}
>
<Power className="w-3.5 h-3.5" />
<span className="flex-1 text-left">{p.name}</span>
<span className="opacity-50 text-[10px]">{p.version}</span>
{enabled && <span className="text-[10px]"></span>}
</button>
);
})}
</div>
)}
{/* Changelog */}
<button
onClick={() => { setSettingsOpen(false); setView("changelog"); setSidebarOpen(false); window.history.pushState(null, "", "#/changelog"); }}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors mt-1"
style={{ background: "var(--bg-hover)", color: "var(--text-secondary)" }}
>
<BookOpen className="w-3.5 h-3.5" />
Changelog
<span className="ml-auto opacity-50">v1.2.0</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* Search - Compact */}
<button
onClick={() => setSearchOpen(true)}
className="search-trigger mx-2 mt-2 flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs transition-colors"
>
<MagnifyingGlass className="w-3.5 h-3.5" />
<span className="flex-1 text-left">{t("sidebar.search")}</span>
<kbd className="text-[10px] px-1 py-0.5 rounded border opacity-60" style={{ background: "var(--bg-hover)", borderColor: "var(--border)" }}>
K
</kbd>
</button>
{/* Agent Selector */}
{showAgentSelector && (
<div className="mx-2 mt-2 relative">
<button
onClick={(e) => { e.stopPropagation(); setAgentSelectorOpen(!agentSelectorOpen); }}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md text-sm transition-colors hover:bg-white/5"
style={{ background: "var(--bg-hover)", color: "var(--text-secondary)" }}
>
<span className="text-base">{agentsState.selectedAgent?.emoji || "🤖"}</span>
<span className="truncate flex-1 text-left text-xs font-medium">{agentsState.selectedAgent?.name || "Select Agent"}</span>
{agentSelectorOpen ? <CaretUp className="w-3 h-3 shrink-0" /> : <CaretDown className="w-3 h-3 shrink-0" />}
</button>
{agentSelectorOpen && (
<div
className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-xl z-50 py-1 max-h-80 overflow-y-auto"
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
onClick={(e) => e.stopPropagation()}
>
{agentsState.agents.map((agent) => (
<button
key={agent.id}
onClick={() => switchAgent(agent.id)}
className="w-full flex flex-col px-3 py-2 text-sm transition-colors hover:bg-white/5"
style={{ color: agent.id === agentsState.selectedAgentId ? "#3b82f6" : "var(--text-secondary)" }}
>
<div className="flex items-center gap-2 w-full">
<span className="text-base">{agent.emoji}</span>
<span className="truncate flex-1 text-left font-medium">{agent.name}</span>
{agent.id === agentsState.selectedAgentId && <span className="text-xs"></span>}
</div>
{agent.skills && agent.skills.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1 ml-7">
{agent.skills.map((skill) => (
<span
key={skill}
className="text-[10px] px-1.5 py-0.5 rounded-full"
style={{ background: "var(--bg-hover)", color: "var(--text-muted)" }}
>
{skill}
</span>
))}
</div>
)}
</button>
))}
</div>
)}
</div>
)}
{/* Quick Access - Collapsible */}
<div className="mx-2 mt-2">
<button
onClick={() => {
const newState = !quickAccessOpen;
setQuickAccessOpen(newState);
localStorage.setItem("memory-viewer-quickaccess-open", String(newState));
}}
className="w-full flex items-center justify-between px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider transition-colors hover:text-blue-400"
style={{ color: "var(--text-muted)" }}
>
{t("sidebar.quickAccess") || "Quick Access"}
{quickAccessOpen ? <CaretDown className="w-3 h-3" /> : <CaretRight className="w-3 h-3" />}
</button>
{quickAccessOpen && (
<div className="flex flex-col">
<button
onClick={() => openFile(todayFile)}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors hover:bg-white/5"
style={{
color: view === "file" && activeFile === todayFile ? "var(--link)" : "var(--text-secondary)",
background: view === "file" && activeFile === todayFile ? "var(--bg-active)" : undefined,
}}
>
<Calendar className="w-4 h-4 text-green-400" />
{t("sidebar.today") || "Today"}
</button>
<button
onClick={() => { setView("timeline"); setSidebarOpen(false); window.history.pushState(null, "", "#/timeline"); }}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors hover:bg-white/5"
style={{
color: view === "timeline" ? "var(--link)" : "var(--text-secondary)",
background: view === "timeline" ? "var(--bg-active)" : undefined,
}}
>
<Clock className="w-4 h-4 text-amber-400" />
{t("sidebar.timeline") || "Timeline"}
</button>
<button
onClick={() => { setView("tags"); setSidebarOpen(false); window.history.pushState(null, "", "#/tags"); }}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors hover:bg-white/5"
style={{
color: view === "tags" ? "var(--link)" : "var(--text-secondary)",
background: view === "tags" ? "var(--bg-active)" : undefined,
}}
>
<Tag className="w-4 h-4 text-pink-400" />
{t("sidebar.tags") || "Tags"}
</button>
<button
onClick={() => { setView("cron"); setSidebarOpen(false); window.history.pushState(null, "", "#/cron"); }}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors hover:bg-white/5"
style={{
color: view === "cron" ? "var(--link)" : "var(--text-secondary)",
background: view === "cron" ? "var(--bg-active)" : undefined,
}}
>
<Timer className="w-4 h-4 text-indigo-400" />
{t("sidebar.cron")}
</button>
<button
onClick={goHome}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors hover:bg-white/5"
style={{
color: view === "dashboard" ? "var(--link)" : "var(--text-secondary)",
background: view === "dashboard" ? "var(--bg-active)" : undefined,
}}
>
<SquaresFour className="w-4 h-4 text-blue-400" />
{t("dashboard.title")}
</button>
{skills.length > 0 && (
<button
onClick={() => { setView("skills"); setSidebarOpen(false); window.history.pushState(null, "", "#/skills"); }}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors hover:bg-white/5"
style={{
color: view === "skills" ? "var(--link)" : "var(--text-secondary)",
background: view === "skills" ? "var(--bg-active)" : undefined,
}}
>
<PuzzlePiece className="w-4 h-4 text-purple-400" />
{t("sidebar.skills")}
<span className="ml-auto text-[10px] opacity-50">{skills.length}</span>
</button>
)}
<button
onClick={() => { setView("settings"); setSidebarOpen(false); window.history.pushState(null, "", "#/settings"); }}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors hover:bg-white/5"
style={{
color: view === "settings" ? "var(--link)" : "var(--text-secondary)",
background: view === "settings" ? "var(--bg-active)" : undefined,
}}
>
<Gear className="w-4 h-4 text-gray-400" />
{t("settings.title")}
</button>
</div>
)}
</div>
{/* File Browser - Main content area */}
<div className="flex-1 overflow-y-auto px-2 py-3">
<FileTree nodes={files} activeFile={activeFile} onSelect={openFile} />
</div>
{/* Agent Selector removed from bottom - moved to top */}
{/* Bot Selector - Bottom */}
<div className="border-t px-2 py-2 relative" style={{ borderColor: "var(--border)" }}>
<button
onClick={(e) => { e.stopPropagation(); setBotSelectorOpen(!botSelectorOpen); }}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors hover:bg-white/5"
style={{ color: "var(--text-secondary)" }}
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ background: online ? "#22c55e" : "#ef4444" }}
/>
<span className="truncate flex-1 text-left text-xs">{connState.active.name}</span>
{botSelectorOpen ? <CaretDown className="w-3 h-3 shrink-0" /> : <CaretUp className="w-3 h-3 shrink-0" />}
</button>
{/* Dropdown - opens upward */}
{botSelectorOpen && (
<div
className="absolute left-2 right-2 bottom-full mb-1 rounded-lg shadow-xl z-50 py-1 max-h-60 overflow-y-auto"
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
onClick={(e) => e.stopPropagation()}
>
{connState.connections.map((conn) => {
const s = connState.statuses[conn.id] ?? (conn.isLocal ? true : false);
return (
<button
key={conn.id}
onClick={() => switchBot(conn.id)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors hover:bg-white/5"
style={{ color: conn.id === connState.active.id ? "#3b82f6" : "var(--text-secondary)" }}
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: s ? "#22c55e" : "#ef4444" }} />
<span className="truncate flex-1 text-left">{conn.name}</span>
{conn.id === connState.active.id && <span className="text-xs"></span>}
</button>
);
})}
<div className="border-t my-1" style={{ borderColor: "var(--border)" }} />
<button
onClick={() => { setBotSelectorOpen(false); setView("connections"); setSidebarOpen(false); window.history.pushState(null, "", "#/connections"); }}
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors hover:bg-white/5"
style={{ color: "var(--text-muted)" }}
>
<ShareNetwork className="w-3.5 h-3.5" />
{t("connections.manage")}
</button>
</div>
)}
</div>
{/* Resize handle - wider touch target on tablet */}
<div
className="hidden lg:block absolute top-0 right-0 w-2 h-full cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors touch-none"
style={{ zIndex: 50 }}
onMouseDown={onResizeMouseDown}
onTouchStart={onResizeTouchStart}
/>
</aside>
{/* Main content */}
<main className="flex-1 flex flex-col min-w-0">
{/* Mobile top bar */}
<div className="lg:hidden flex items-center gap-3 px-4 py-2.5 border-b backdrop-blur shrink-0" style={{ borderColor: "var(--border)", background: "var(--bg-secondary)" }}>
<button onClick={() => setSidebarOpen(true)} style={{ color: "var(--text-muted)" }}>
<List className="w-6 h-6" />
</button>
<span className="text-sm font-medium truncate">
{view === "file" ? activeFile : view === "changelog" ? t("changelog.title") : view === "connections" ? t("connections.title") : view === "agent-status" ? t("sidebar.agentConfig") : view === "timeline" ? t("timeline.title") : view === "tags" ? t("tags.title") : view === "cron" ? t("cron.title") : view === "settings" ? t("settings.title") : t("dashboard.title")}
</span>
<button onClick={() => window.location.reload()} className="ml-auto p-1" style={{ color: "var(--text-muted)" }} title="Refresh">
<ArrowsClockwise className="w-5 h-5" />
</button>
<button onClick={sensitive.toggle} className="p-1" style={{ color: "var(--text-muted)" }}>
{sensitive.hidden ? <EyeSlash className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
<button onClick={toggleTheme} className="p-1" style={{ color: "var(--text-muted)" }}>
{theme === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
<button onClick={() => setSearchOpen(true)} style={{ color: "var(--text-muted)" }}>
<MagnifyingGlass className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-hidden">
{view === "changelog" ? (
<Changelog onBack={goHome} />
) : view === "agent-status" ? (
<AgentStatusPage />
) : view === "skills" ? (
<SkillsPage skills={skills} onOpenFile={openFile} />
) : view === "connections" ? (
<div className="h-full overflow-auto">
<Connections
connections={connState.connections}
statuses={connState.statuses}
activeId={connState.active.id}
onAdd={connState.addConnection}
onUpdate={connState.updateConnection}
onRemove={connState.removeConnection}
onSwitch={switchBot}
onRefresh={connState.checkStatuses}
/>
</div>
) : view === "timeline" ? (
<div className="h-full overflow-auto">
<Timeline onOpenFile={openFile} />
</div>
) : view === "tags" ? (
<div className="h-full overflow-auto">
<Tags onOpenFile={openFile} />
</div>
) : view === "cron" ? (
<CronManager />
) : view === "settings" ? (
<SettingsPage onBack={goHome} />
) : view === "dashboard" ? (
<div className="h-full overflow-auto">
<Dashboard onOpenFile={openFile} />
</div>
) : (
<FileViewer filePath={activeFile} refreshKey={refreshKey} onOpenFile={openFile} />
)}
</div>
</main>
{/* Search modal */}
{searchOpen && (
<SearchPanel onSelect={openFile} onClose={() => setSearchOpen(false)} />
)}
</div>
</SensitiveProvider>
</LocaleContext.Provider>
);
}

404
memory-viewer/src/api.ts Normal file
View File

@@ -0,0 +1,404 @@
/** API client for Memory Viewer backend. */
let _baseUrl = "";
let _currentAgent: string | null = null;
export function getBaseUrl(): string {
return _baseUrl;
}
export function setBaseUrl(url: string) {
_baseUrl = url.replace(/\/+$/, "");
}
export function getCurrentAgent(): string | null {
return _currentAgent;
}
export function setCurrentAgent(agentId: string | null) {
_currentAgent = agentId;
}
// Helper to build URL with agent parameter
function buildUrl(endpoint: string, params?: Record<string, string>): string {
const base = _baseUrl || window.location.origin;
const url = new URL(`${base}${endpoint}`);
if (_currentAgent) {
url.searchParams.set("agent", _currentAgent);
}
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
return url.toString();
}
export interface FileNode {
name: string;
type: "file" | "dir";
path: string;
children?: FileNode[];
}
export interface FileData {
content: string;
mtime: string;
size: number;
}
export interface SystemInfo {
uptime: number;
memTotal: number;
memFree: number;
memUsed: number;
load: number[];
platform: string;
hostname: string;
totalFiles: number;
todayMemory: {
filename: string;
snippet: string;
length: number;
} | null;
}
export interface SearchResult {
path: string;
matches: { line: number; text: string }[];
}
export interface BotInfo {
name: string;
version: string;
description: string;
}
export interface SkillInfo { id: string; name: string; description: string; path: string; }
// Agent types
export interface AgentInfo {
id: string;
name: string;
workspace: string;
emoji: string;
skills?: string[];
}
export async function fetchAgents(): Promise<AgentInfo[]> {
const r = await fetch(`${_baseUrl}/api/agents`);
if (!r.ok) throw new Error("Failed to load agents");
return r.json();
}
export async function fetchSkills(): Promise<SkillInfo[]> {
const r = await fetch(buildUrl("/api/skills"));
return r.json();
}
export async function fetchFiles(): Promise<FileNode[]> {
const r = await fetch(buildUrl("/api/files"));
return r.json();
}
export async function fetchFile(path: string): Promise<FileData> {
const r = await fetch(buildUrl("/api/file", { path }));
if (!r.ok) throw new Error("Failed to load file");
return r.json();
}
export interface SaveResult {
ok: boolean;
mtime: string;
}
export interface ConflictResult {
error: "conflict";
message: string;
serverMtime: string;
serverContent: string;
}
export async function saveFile(
path: string,
content: string,
expectedMtime?: string
): Promise<SaveResult | ConflictResult> {
const r = await fetch(buildUrl("/api/file"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content, expectedMtime }),
});
return r.json();
}
export async function fetchSystem(): Promise<SystemInfo> {
const r = await fetch(buildUrl("/api/system"));
return r.json();
}
export async function searchFiles(query: string): Promise<SearchResult[]> {
const r = await fetch(buildUrl("/api/search", { q: query }));
return r.json();
}
export interface SemanticResult {
path: string;
title: string;
snippet: string;
score: number;
}
export async function semanticSearch(query: string, mode: "bm25" | "vector" = "bm25"): Promise<SemanticResult[]> {
const r = await fetch(buildUrl("/api/semantic-search", { q: query, mode }));
return r.json();
}
export interface RecentFile {
path: string;
mtime: number;
size: number;
}
export interface MonthlyStats {
month: string;
count: number;
}
export interface TimelineEntry {
date: string;
path: string;
title: string;
preview: string;
tags: string[];
charCount: number;
}
export async function fetchTimeline(): Promise<TimelineEntry[]> {
const r = await fetch(buildUrl("/api/timeline"));
return r.json();
}
// ============================================================================
// Tags API
// ============================================================================
export interface TagInfo {
name: string;
count: number;
files: string[];
}
export interface FileWithTags {
path: string;
title: string;
preview: string;
date: string;
tags: string[];
}
export async function fetchTags(): Promise<TagInfo[]> {
const r = await fetch(buildUrl("/api/tags"));
return r.json();
}
export async function fetchFilesByTag(tag: string): Promise<FileWithTags[]> {
const r = await fetch(buildUrl(`/api/files-by-tag/${encodeURIComponent(tag)}`));
return r.json();
}
export async function fetchRecent(limit = 10): Promise<RecentFile[]> {
const r = await fetch(buildUrl("/api/recent", { limit: String(limit) }));
return r.json();
}
export async function fetchMonthlyStats(): Promise<MonthlyStats[]> {
const r = await fetch(buildUrl("/api/stats/monthly"));
return r.json();
}
export interface DailyStats { date: string; count: number; size: number; }
export async function fetchDailyStats(): Promise<DailyStats[]> {
const r = await fetch(buildUrl("/api/stats/daily"));
return r.json();
}
export async function fetchBotInfo(baseUrl = ""): Promise<BotInfo> {
const url = baseUrl ? baseUrl.replace(/\/+$/, "") : _baseUrl;
const r = await fetch(`${url}/api/info${_currentAgent ? `?agent=${_currentAgent}` : ""}`);
return r.json();
}
export async function checkConnection(baseUrl: string): Promise<boolean> {
try {
const r = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/system`, { signal: AbortSignal.timeout(5000) });
return r.ok;
} catch {
return false;
}
}
export interface AgentStatus {
config: any;
gateway: any;
heartbeat: any;
}
export interface WikilinkResolution {
found: boolean;
path: string | null;
}
export async function resolveWikilink(link: string): Promise<WikilinkResolution> {
const r = await fetch(buildUrl("/api/resolve-wikilink", { link }));
return r.json();
}
export interface SummarizeResult {
summary: string;
saved: boolean;
mtime?: string;
error?: string;
}
export async function summarizeFile(path: string, save = false): Promise<SummarizeResult> {
const r = await fetch(buildUrl("/api/summarize"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, save }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || "Summarize failed");
return data;
}
export async function fetchAgentStatus(): Promise<AgentStatus> {
const r = await fetch(`${_baseUrl}/api/agent/status${_currentAgent ? `?agent=${_currentAgent}` : ""}`);
return r.json();
}
// ============================================================================
// Cron API
// ============================================================================
export interface CronJob {
id: string;
name: string;
enabled: boolean;
schedule: string;
scheduleRaw: any;
nextRun: string | null;
lastRun: string | null;
lastStatus: string | null;
sessionTarget: string;
wakeMode: string;
payloadKind: string;
deliveryMode: string;
}
export interface CronRun {
status: string;
startedAt?: string;
runAtMs?: number;
ts?: number;
completedAt?: string;
sessionKey?: string;
durationMs?: number;
}
export interface SystemCron {
id: string;
name: string;
type: string;
schedule: string;
enabled: boolean;
description: string;
agents?: { id: string; name: string; heartbeat: string; enabled: boolean }[];
}
export async function fetchSystemCrons(): Promise<SystemCron[]> {
const r = await fetch(buildUrl("/api/system-crons"));
const data = await r.json();
return data.systemCrons || [];
}
export async function fetchCronJobs(): Promise<CronJob[]> {
const r = await fetch(buildUrl("/api/crons"));
const data = await r.json();
return data.crons || [];
}
export async function fetchCronRuns(jobId: string): Promise<CronRun[]> {
const r = await fetch(buildUrl(`/api/crons/${jobId}/runs`));
const data = await r.json();
return data.runs || [];
}
export async function toggleCronJob(jobId: string, enabled: boolean): Promise<{ success: boolean }> {
const r = await fetch(buildUrl(`/api/crons/${jobId}/toggle`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
return r.json();
}
export async function runCronJob(jobId: string): Promise<{ success: boolean; result?: string; error?: string }> {
const r = await fetch(buildUrl(`/api/crons/${jobId}/run`), {
method: "POST",
headers: { "Content-Type": "application/json" },
});
return r.json();
}
export interface EmbeddingSettings {
enabled: boolean;
apiUrl: string;
apiKey: string;
model: string;
apiKeySet?: boolean;
}
export interface Settings {
embedding: EmbeddingSettings;
}
export interface Capabilities {
qmd: boolean;
qmdBm25: boolean;
qmdVector: boolean;
embeddingApi: boolean;
}
export async function fetchCapabilities(): Promise<Capabilities> {
try {
const r = await fetch(buildUrl("/api/capabilities"));
return r.json();
} catch {
return { qmd: false, qmdBm25: false, qmdVector: false, embeddingApi: false };
}
}
export async function fetchSettings(): Promise<Settings> {
const r = await fetch(buildUrl("/api/settings"));
return r.json();
}
export async function saveSettings(settings: Partial<Settings>): Promise<{ success: boolean }> {
const r = await fetch(buildUrl("/api/settings"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
return r.json();
}
export async function testEmbeddingConnection(settings?: Partial<EmbeddingSettings>): Promise<{ success: boolean; error?: string }> {
const r = await fetch(buildUrl("/api/settings/test-embedding"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings || {}),
});
return r.json();
}

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { AgentStatusPage } from '../components/AgentStatus';
import { LocaleContext } from '../hooks/useLocale';
// Mock dependencies
vi.mock('../api', () => ({
fetchAgentStatus: vi.fn().mockResolvedValue({
config: { version: '1.2.0', update: { channel: 'stable' } },
gateway: { runtime: { status: 'running', pid: 1234 } },
heartbeat: { lastRun: Date.now() }
})
}));
// Mock shiki
vi.mock('shiki', () => ({
createHighlighter: vi.fn().mockResolvedValue({
codeToHtml: () => '<pre>mock code</pre>'
})
}));
describe('AgentStatusPage', () => {
it('renders loading state initially', () => {
// We can't easily test loading state with async useEffect,
// but we can test the happy path after wait
});
it('renders status after load', async () => {
const mockLocale = { t: (k: string) => k, toggleLocale: () => {}, locale: 'en' as const };
render(
<LocaleContext.Provider value={mockLocale}>
<AgentStatusPage />
</LocaleContext.Provider>
);
// Should eventually show the version
expect(await screen.findByText('v1.2.0')).toBeInTheDocument();
// Should show gateway running status key
expect(screen.getByText('agent.running')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,236 @@
import { useEffect, useState } from "react";
import { fetchAgentStatus, type AgentStatus } from "../api";
import { createHighlighter } from "shiki";
import { Pulse, HardDrives, Shield, Cpu, Clock, CaretDown, CaretRight, CheckCircle, XCircle, Heartbeat, Lightning } from "@phosphor-icons/react";
import { useLocale } from "../hooks/useLocale";
function StatusCard({ title, icon: Icon, children, className = "" }: any) {
return (
<div className={`p-5 rounded-xl border transition-all hover:shadow-md ${className}`} style={{ background: "var(--bg-secondary)", borderColor: "var(--border)" }}>
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-400">
<Icon className="w-5 h-5" />
</div>
<h3 className="font-semibold text-lg">{title}</h3>
</div>
{children}
</div>
);
}
function StatusRow({ label, value, sub }: any) {
return (
<div className="flex items-center justify-between py-2 border-b last:border-0" style={{ borderColor: "var(--border-muted)" }}>
<span style={{ color: "var(--text-secondary)" }}>{label}</span>
<div className="text-right">
<div className="font-medium" style={{ color: "var(--text-primary)" }}>{value}</div>
{sub && <div className="text-xs" style={{ color: "var(--text-muted)" }}>{sub}</div>}
</div>
</div>
);
}
function TimeAgo({ date }: { date: string | number }) {
if (!date) return <span>-</span>;
const d = new Date(date);
const now = new Date();
const diff = Math.floor((now.getTime() - d.getTime()) / 1000);
let text = "";
if (diff < 60) text = `${diff}s ago`;
else if (diff < 3600) text = `${Math.floor(diff / 60)}m ago`;
else if (diff < 86400) text = `${Math.floor(diff / 3600)}h ago`;
else text = `${Math.floor(diff / 86400)}d ago`;
return <span title={d.toLocaleString()}>{text}</span>;
}
// Module-level cache so switching tabs doesn't re-fetch
let _cache: { data: AgentStatus; html: string } | null = null;
export function AgentStatusPage() {
const [data, setData] = useState<AgentStatus | null>(_cache?.data ?? null);
const [loading, setLoading] = useState(!_cache);
const [html, setHtml] = useState(_cache?.html ?? "");
const [configExpanded, setConfigExpanded] = useState(false);
const { t } = useLocale();
useEffect(() => {
if (_cache) return; // Already have data, skip fetch
fetchAgentStatus()
.then(d => {
setData(d);
setLoading(false);
// Highlight config with dual theme
createHighlighter({
themes: ['github-dark', 'github-light'],
langs: ['json']
}).then(highlighter => {
const code = JSON.stringify(d.config, null, 2);
const out = highlighter.codeToHtml(code, {
lang: 'json',
themes: { dark: 'github-dark', light: 'github-light' },
defaultColor: false,
});
setHtml(out);
_cache = { data: d, html: out };
});
})
.catch(err => {
console.error(err);
setLoading(false);
});
}, []);
if (loading) {
return (
<div className="h-full overflow-y-auto p-6 lg:p-10">
<div className="max-w-5xl mx-auto space-y-8 animate-pulse">
{/* Header skeleton */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-lg" style={{ background: "var(--bg-hover)" }} />
<div className="h-8 w-48 rounded-lg" style={{ background: "var(--bg-hover)" }} />
</div>
<div className="h-4 w-32 rounded ml-11 mt-2" style={{ background: "var(--bg-hover)" }} />
</div>
{/* Cards skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="p-5 rounded-xl border" style={{ background: "var(--bg-secondary)", borderColor: "var(--border)" }}>
<div className="flex items-center gap-2 mb-4">
<div className="w-9 h-9 rounded-lg" style={{ background: "var(--bg-hover)" }} />
<div className="h-5 w-24 rounded" style={{ background: "var(--bg-hover)" }} />
</div>
<div className="space-y-3">
<div className="h-10 rounded-lg" style={{ background: "var(--bg-hover)", opacity: 0.6 }} />
<div className="h-4 w-full rounded" style={{ background: "var(--bg-hover)", opacity: 0.4 }} />
<div className="h-4 w-3/4 rounded" style={{ background: "var(--bg-hover)", opacity: 0.4 }} />
<div className="h-4 w-2/3 rounded" style={{ background: "var(--bg-hover)", opacity: 0.4 }} />
</div>
</div>
))}
</div>
{/* Config skeleton */}
<div className="h-14 rounded-xl border" style={{ background: "var(--bg-secondary)", borderColor: "var(--border)" }} />
</div>
</div>
);
}
if (!data) {
return (
<div className="p-8 text-center text-red-400">
{t("agent.error")}
</div>
);
}
const gw = data.gateway || {};
// Support both flat (gw.runtime) and nested (gw.service.runtime) structures
const runtime = gw.runtime || gw.service?.runtime || {};
const gwInfo = gw.gateway || {};
const isGwRunning = runtime.status === "running" || runtime.state === "active" || (runtime.pid && runtime.pid > 0);
const hb = data.heartbeat || {};
const checks = hb.checks || {};
return (
<div className="h-full overflow-y-auto p-6 lg:p-10">
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2 flex items-center gap-3">
<Pulse className="w-8 h-8 text-blue-500" />
{t("agent.title")}
</h1>
<div className="flex items-center gap-3 text-sm pl-11" style={{ color: "var(--text-secondary)" }}>
<span className="bg-white/10 px-2 py-0.5 rounded border border-white/10">v{data.config.version || "0.0.0"}</span>
<span>{data.config.update?.channel || "stable"} {t("agent.channel")}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Gateway Card */}
<StatusCard title={t("agent.gateway")} icon={HardDrives}>
<div className="flex items-center gap-3 mb-6 p-3 rounded-lg bg-black/20">
<div className={`w-3 h-3 rounded-full ${isGwRunning ? "bg-green-500 shadow-[0_0_10px_#22c55e]" : "bg-red-500"}`} />
<span className="font-medium text-lg">{isGwRunning ? t("agent.running") : t("agent.stopped")}</span>
{isGwRunning && gw.gateway?.uptime && <span className="ml-auto text-xs opacity-60"><TimeAgo date={Date.now() - (gw.gateway.uptime * 1000)} /> {t("agent.uptime")}</span>}
</div>
<div className="space-y-1">
<StatusRow label={t("agent.port")} value={gw.gateway?.port || data.config.gateway?.port || "-"} />
<StatusRow label={t("agent.pid")} value={runtime.pid || "-"} />
<StatusRow label={t("agent.mode")} value={gw.gateway?.bindMode || data.config.gateway?.mode || "-"} />
</div>
</StatusCard>
{/* Models Card */}
<StatusCard title={t("agent.models")} icon={Cpu}>
<div className="mb-4">
<div className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>{t("agent.primary")}</div>
<div className="font-mono text-sm p-2 rounded bg-black/20 border border-white/5 truncate" title={data.config.agents?.defaults?.model?.primary || "default"}>
{data.config.agents?.defaults?.model?.primary || "default"}
</div>
</div>
{/* If we had more model stats they would go here */}
<div className="flex items-center gap-2 mt-4 text-sm" style={{ color: "var(--text-secondary)" }}>
<Lightning className="w-4 h-4 text-yellow-400" />
<span>{t("agent.ready")}</span>
</div>
</StatusCard>
{/* Heartbeat Card */}
<StatusCard title={t("agent.heartbeat")} icon={Heartbeat}>
<div className="flex flex-col items-center justify-center py-4">
{hb.lastRun ? (
<>
<div className="text-4xl font-bold mb-1 text-green-400">
<TimeAgo date={hb.lastRun} />
</div>
<div className="text-sm opacity-60">{t("agent.lastActivity")}</div>
</>
) : (
<div className="text-center opacity-50">{t("agent.noHeartbeat")}</div>
)}
</div>
<div className="space-y-1 mt-2">
{Object.entries(checks).map(([k, v]) => (
<StatusRow key={k} label={k} value={v ? <TimeAgo date={v as number} /> : "-"} />
))}
</div>
</StatusCard>
</div>
{/* Config Section */}
<div className="rounded-xl border overflow-hidden" style={{ background: "var(--bg-secondary)", borderColor: "var(--border)" }}>
<button
onClick={() => setConfigExpanded(!configExpanded)}
className="w-full flex items-center justify-between p-4 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-purple-400" />
<span className="font-semibold">{t("agent.config")}</span>
</div>
{configExpanded ? <CaretDown className="w-5 h-5" /> : <CaretRight className="w-5 h-5" />}
</button>
{configExpanded && (
<div className="p-0 border-t" style={{ borderColor: "var(--border)" }}>
<div
className="p-4 overflow-x-auto text-sm font-mono shiki-wrapper"
style={{ background: "var(--bg-primary)" }}
dangerouslySetInnerHTML={{ __html: html || "<pre>Loading...</pre>" }}
/>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { useState, useEffect, useCallback } from "react";
import { SpinnerGap, CheckCircle, XCircle, Rocket, ArrowRight } from "@phosphor-icons/react";
import { useLocale } from "../hooks/useLocale";
type Step = "checking" | "confirm" | "installing" | "verifying" | "success" | "failed";
interface BootstrapWizardProps {
name: string;
gatewayUrl: string;
token: string;
mvPort: number;
onComplete: (mvUrl: string) => void;
onCancel: () => void;
}
function extractHost(gatewayUrl: string): string {
try {
return new URL(gatewayUrl).hostname;
} catch {
return gatewayUrl.replace(/^https?:\/\//, "").split(":")[0].split("/")[0];
}
}
const INSTALL_PROMPT = `Please install and start the Memory Viewer server. Run these commands:
1. Clone the repo:
cd ~ && git clone https://github.com/silicondawn/memory-viewer.git
2. Install dependencies and build:
cd memory-viewer && npm install && npm run build
3. Start the server (replace WORKSPACE_DIR with your actual workspace path):
PORT=8901 WORKSPACE_DIR=~/clawd node --import tsx/esm server/index.ts &
4. Verify it's running:
curl -s http://localhost:8901/api/system
Please run these commands and confirm when the server is running.`;
export function BootstrapWizard({ name, gatewayUrl, token, mvPort, onComplete, onCancel }: BootstrapWizardProps) {
const { t } = useLocale();
const [step, setStep] = useState<Step>("checking");
const [botResponse, setBotResponse] = useState("");
const [error, setError] = useState("");
const host = extractHost(gatewayUrl);
const mvUrl = `http://${host}:${mvPort}`;
const checkConnection = useCallback(async (): Promise<boolean> => {
try {
const r = await fetch(`${mvUrl}/api/system`, { signal: AbortSignal.timeout(5000) });
return r.ok;
} catch {
return false;
}
}, [mvUrl]);
// Step 1: initial check
useEffect(() => {
if (step !== "checking") return;
checkConnection().then((ok) => {
if (ok) {
onComplete(mvUrl);
setStep("success");
} else {
setStep("confirm");
}
});
}, [step, checkConnection, mvUrl, onComplete]);
const doInstall = async () => {
setStep("installing");
setBotResponse("");
setError("");
try {
const resp = await fetch("/api/gateway/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
gatewayUrl,
token,
messages: [{ role: "user", content: INSTALL_PROMPT.replace(/PORT=8901/g, `PORT=${mvPort}`) }],
}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({ error: resp.statusText }));
throw new Error(data.error || `HTTP ${resp.status}`);
}
const data = await resp.json();
const content = data.choices?.[0]?.message?.content || JSON.stringify(data);
setBotResponse(content);
// Step 4: verify
setStep("verifying");
// Wait a bit then check
await new Promise((r) => setTimeout(r, 3000));
const ok = await checkConnection();
if (ok) {
onComplete(mvUrl);
setStep("success");
} else {
// Retry a few times
for (let i = 0; i < 3; i++) {
await new Promise((r) => setTimeout(r, 5000));
if (await checkConnection()) {
onComplete(mvUrl);
setStep("success");
return;
}
}
setStep("failed");
}
} catch (err: any) {
setError(err.message);
setStep("failed");
}
};
const retryVerify = async () => {
setStep("verifying");
await new Promise((r) => setTimeout(r, 2000));
const ok = await checkConnection();
if (ok) {
onComplete(mvUrl);
setStep("success");
} else {
setStep("failed");
}
};
const inputStyle = { background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" };
return (
<div className="rounded-xl p-5 space-y-4" style={{ background: "var(--bg-tertiary)", border: "1px solid var(--border)" }}>
<h3 className="font-semibold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<Rocket className="w-5 h-5 text-blue-400" /> Bootstrap: {name}
</h3>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
{mvUrl}
</div>
{step === "checking" && (
<div className="flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
<SpinnerGap className="w-4 h-4 animate-spin" /> {t("bootstrap.checking")}
</div>
)}
{step === "confirm" && (
<div className="space-y-3">
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>{t("bootstrap.notInstalled")}</p>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{t("bootstrap.installPrompt")}</p>
<div className="flex gap-2">
<button onClick={doInstall} className="btn-secondary text-sm flex items-center gap-1">
<ArrowRight className="w-3.5 h-3.5" /> {t("bootstrap.install")}
</button>
<button onClick={onCancel} className="btn-secondary text-sm">{t("connections.cancel")}</button>
</div>
</div>
)}
{step === "installing" && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
<SpinnerGap className="w-4 h-4 animate-spin" /> {t("bootstrap.installing")}
</div>
</div>
)}
{step === "verifying" && (
<div className="space-y-2">
{botResponse && (
<div className="text-xs p-3 rounded-lg max-h-48 overflow-auto whitespace-pre-wrap" style={inputStyle}>
<div className="font-semibold mb-1" style={{ color: "var(--text-muted)" }}>{t("bootstrap.botResponse")}:</div>
{botResponse}
</div>
)}
<div className="flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
<SpinnerGap className="w-4 h-4 animate-spin" /> {t("bootstrap.verifying")}
</div>
</div>
)}
{step === "success" && (
<div className="flex items-center gap-2 text-sm text-green-400">
<CheckCircle className="w-4 h-4" /> {t("bootstrap.success")}
</div>
)}
{step === "failed" && (
<div className="space-y-3">
{botResponse && (
<div className="text-xs p-3 rounded-lg max-h-48 overflow-auto whitespace-pre-wrap" style={inputStyle}>
<div className="font-semibold mb-1" style={{ color: "var(--text-muted)" }}>{t("bootstrap.botResponse")}:</div>
{botResponse}
</div>
)}
{error && (
<div className="text-xs text-red-400">{t("bootstrap.error")}: {error}</div>
)}
<div className="text-sm" style={{ color: "var(--text-secondary)" }}>{t("bootstrap.failed")}</div>
<div className="flex gap-2">
<button onClick={retryVerify} className="btn-secondary text-sm">{t("bootstrap.retry")}</button>
<button onClick={onCancel} className="btn-secondary text-sm">{t("bootstrap.close")}</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useLocale } from "../hooks/useLocale";
import { ArrowLeft } from "@phosphor-icons/react";
// Bundled at build time via ?raw
import changelogRaw from "../../CHANGELOG.md?raw";
export function Changelog({ onBack }: { onBack: () => void }) {
const { t } = useLocale();
return (
<div className="h-full overflow-auto">
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-sm mb-6 transition-colors hover:text-blue-400"
style={{ color: "var(--text-muted)" }}
>
<ArrowLeft className="w-4 h-4" />
{t("changelog.back")}
</button>
<article className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{changelogRaw}
</ReactMarkdown>
</article>
</div>
</div>
);
}

View File

@@ -0,0 +1,275 @@
import { useState } from "react";
import { type BotConnection } from "../hooks/useConnections";
import { ShareNetwork, Plus, Trash, PencilSimple, Check, X, ArrowsClockwise } from "@phosphor-icons/react";
import { useLocale } from "../hooks/useLocale";
import { BootstrapWizard } from "./BootstrapWizard";
interface ConnectionsProps {
connections: BotConnection[];
statuses: Record<string, boolean>;
activeId: string;
onAdd: (conn: Omit<BotConnection, "id">) => void;
onUpdate: (id: string, updates: Partial<BotConnection>) => void;
onRemove: (id: string) => void;
onSwitch: (id: string) => void;
onRefresh: () => void;
}
type ConnMode = "direct" | "gateway";
interface GatewayBootstrap {
name: string;
gatewayUrl: string;
token: string;
mvPort: number;
}
export function Connections({ connections, statuses, activeId, onAdd, onUpdate, onRemove, onSwitch, onRefresh }: ConnectionsProps) {
const { t } = useLocale();
const [showForm, setShowForm] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [mode, setMode] = useState<ConnMode>("direct");
const [form, setForm] = useState({ name: "", url: "", token: "" });
const [gwForm, setGwForm] = useState({ name: "", gatewayUrl: "", token: "", mvPort: "8901" });
const [bootstrap, setBootstrap] = useState<GatewayBootstrap | null>(null);
const startAdd = () => {
setForm({ name: "", url: "", token: "" });
setGwForm({ name: "", gatewayUrl: "", token: "", mvPort: "8901" });
setEditId(null);
setMode("direct");
setShowForm(true);
};
const startEdit = (conn: BotConnection) => {
setForm({ name: conn.name, url: conn.url, token: conn.token || "" });
setEditId(conn.id);
setMode("direct");
setShowForm(true);
};
const save = () => {
if (mode === "direct") {
if (!form.name.trim() || !form.url.trim()) return;
if (editId) {
onUpdate(editId, { name: form.name.trim(), url: form.url.trim(), token: form.token.trim() || undefined });
} else {
onAdd({ name: form.name.trim(), url: form.url.trim(), token: form.token.trim() || undefined });
}
setShowForm(false);
setEditId(null);
setTimeout(onRefresh, 500);
} else {
// Gateway mode — start bootstrap
if (!gwForm.name.trim() || !gwForm.gatewayUrl.trim() || !gwForm.token.trim()) return;
setBootstrap({
name: gwForm.name.trim(),
gatewayUrl: gwForm.gatewayUrl.trim(),
token: gwForm.token.trim(),
mvPort: parseInt(gwForm.mvPort) || 8901,
});
setShowForm(false);
}
};
const handleBootstrapComplete = (mvUrl: string) => {
if (!bootstrap) return;
onAdd({ name: bootstrap.name, url: mvUrl });
setTimeout(onRefresh, 500);
};
const inputStyle = {
background: "var(--bg-hover)",
color: "var(--text-primary)",
border: "1px solid var(--border)",
};
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<ShareNetwork className="w-7 h-7 text-blue-400" /> {t("connections.title")}
</h1>
<div className="flex gap-2">
<button onClick={onRefresh} className="btn-secondary text-sm flex items-center gap-1">
<ArrowsClockwise className="w-3.5 h-3.5" /> {t("connections.refresh")}
</button>
<button onClick={startAdd} className="btn-secondary text-sm flex items-center gap-1">
<Plus className="w-3.5 h-3.5" /> {t("connections.add")}
</button>
</div>
</div>
{/* Bootstrap Wizard */}
{bootstrap && (
<BootstrapWizard
name={bootstrap.name}
gatewayUrl={bootstrap.gatewayUrl}
token={bootstrap.token}
mvPort={bootstrap.mvPort}
onComplete={handleBootstrapComplete}
onCancel={() => setBootstrap(null)}
/>
)}
{/* Add/Edit form */}
{showForm && (
<div className="rounded-xl p-5 space-y-3" style={{ background: "var(--bg-tertiary)", border: "1px solid var(--border)" }}>
<div className="flex items-center justify-between">
<h3 className="font-semibold" style={{ color: "var(--text-primary)" }}>
{editId ? t("connections.edit") : t("connections.addNew")}
</h3>
{!editId && (
<div className="flex rounded-lg overflow-hidden text-xs" style={{ border: "1px solid var(--border)" }}>
<button
className="px-3 py-1.5 transition-colors"
style={{
background: mode === "direct" ? "var(--accent)" : "var(--bg-hover)",
color: mode === "direct" ? "#fff" : "var(--text-secondary)",
}}
onClick={() => setMode("direct")}
>
{t("connections.modeDirect")}
</button>
<button
className="px-3 py-1.5 transition-colors"
style={{
background: mode === "gateway" ? "var(--accent)" : "var(--bg-hover)",
color: mode === "gateway" ? "#fff" : "var(--text-secondary)",
}}
onClick={() => setMode("gateway")}
>
{t("connections.modeGateway")}
</button>
</div>
)}
</div>
{mode === "direct" ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input
className="px-3 py-2 rounded-lg text-sm"
style={inputStyle}
placeholder={t("connections.namePlaceholder")}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<input
className="px-3 py-2 rounded-lg text-sm"
style={inputStyle}
placeholder={t("connections.urlPlaceholder")}
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
/>
<input
className="px-3 py-2 rounded-lg text-sm"
style={inputStyle}
placeholder={t("connections.tokenPlaceholder")}
value={form.token}
onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))}
/>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
className="px-3 py-2 rounded-lg text-sm"
style={inputStyle}
placeholder={t("connections.namePlaceholder")}
value={gwForm.name}
onChange={(e) => setGwForm((f) => ({ ...f, name: e.target.value }))}
/>
<input
className="px-3 py-2 rounded-lg text-sm"
style={inputStyle}
placeholder={t("connections.gatewayUrlPlaceholder")}
value={gwForm.gatewayUrl}
onChange={(e) => setGwForm((f) => ({ ...f, gatewayUrl: e.target.value }))}
/>
<input
className="px-3 py-2 rounded-lg text-sm"
style={inputStyle}
type="password"
placeholder={t("connections.gatewayTokenPlaceholder")}
value={gwForm.token}
onChange={(e) => setGwForm((f) => ({ ...f, token: e.target.value }))}
/>
<input
className="px-3 py-2 rounded-lg text-sm"
style={inputStyle}
placeholder={t("connections.mvPortPlaceholder")}
value={gwForm.mvPort}
onChange={(e) => setGwForm((f) => ({ ...f, mvPort: e.target.value }))}
/>
</div>
)}
<div className="flex gap-2">
<button onClick={save} className="btn-secondary text-sm flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> {mode === "gateway" && !editId ? t("bootstrap.install") : t("connections.save")}
</button>
<button onClick={() => { setShowForm(false); setEditId(null); }} className="btn-secondary text-sm flex items-center gap-1">
<X className="w-3.5 h-3.5" /> {t("connections.cancel")}
</button>
</div>
</div>
)}
{/* Connection list */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{connections.map((conn) => {
const online = statuses[conn.id] ?? false;
const isActive = conn.id === activeId;
return (
<div
key={conn.id}
className="rounded-xl p-4 cursor-pointer transition-all"
style={{
background: "var(--bg-tertiary)",
border: isActive ? "2px solid #3b82f6" : "1px solid var(--border)",
}}
onClick={() => onSwitch(conn.id)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: online ? "#22c55e" : "#ef4444" }}
/>
<span className="font-semibold text-sm" style={{ color: "var(--text-primary)" }}>
{conn.name}
</span>
</div>
{!conn.isLocal && (
<div className="flex gap-1">
<button
onClick={(e) => { e.stopPropagation(); startEdit(conn); }}
className="p-1 rounded hover:opacity-80"
style={{ color: "var(--text-muted)" }}
>
<PencilSimple className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); onRemove(conn.id); }}
className="p-1 rounded hover:opacity-80"
style={{ color: "var(--text-muted)" }}
>
<Trash className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
<div className="text-xs truncate" style={{ color: "var(--text-faint)" }}>
{conn.isLocal ? "localhost (current)" : conn.url}
</div>
{isActive && (
<div className="mt-2 text-xs font-medium text-blue-400">
{t("connections.active")}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { useMemo, useState, useSyncExternalStore } from "react";
import type { DailyStats } from "../api";
import { useLocale } from "../hooks/useLocale";
const CELL = 11;
const GAP = 2;
const STEP = CELL + GAP;
const COLORS_DARK = ["#1a1c2b", "#1b3a2a", "#1a5c35", "#26a641", "#39d353"];
const COLORS_LIGHT = ["#f0e8de", "#d4c4a0", "#b49555", "#92400e", "#78350f"];
function useIsDark() {
return useSyncExternalStore(
(cb) => {
const obs = new MutationObserver(cb);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => obs.disconnect();
},
() => document.documentElement.classList.contains("dark"),
);
}
function getLevel(size: number, thresholds: number[]): number {
if (size === 0) return 0;
for (let i = thresholds.length - 1; i >= 0; i--) {
if (size >= thresholds[i]) return i + 1;
}
return 1;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
interface Props {
data: DailyStats[];
onOpenFile: (path: string) => void;
}
export function ContributionHeatmap({ data, onOpenFile }: Props) {
const { t } = useLocale();
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const isDark = useIsDark();
const colors = isDark ? COLORS_DARK : COLORS_LIGHT;
const { weeks, monthLabels, thresholds } = useMemo(() => {
const map = new Map<string, number>();
for (const d of data) map.set(d.date, d.size);
// Compute thresholds from non-zero sizes
const sizes = data.map((d) => d.size).filter((s) => s > 0).sort((a, b) => a - b);
let th = [1, 1000, 3000, 6000];
if (sizes.length >= 4) {
const q = (i: number) => sizes[Math.floor((i * (sizes.length - 1)) / 4)];
th = [1, q(1), q(2), q(3)];
}
// Build 52 weeks ending today
const today = new Date();
const todayDay = today.getDay(); // 0=Sun
// End of this week (Saturday) or today
const endDate = new Date(today);
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - (52 * 7) - todayDay);
const weeks: { date: string; size: number; dow: number }[][] = [];
const monthLabels: { col: number; label: string }[] = [];
let currentWeek: { date: string; size: number; dow: number }[] = [];
let lastMonth = -1;
const cursor = new Date(startDate);
cursor.setDate(cursor.getDate() + 1); // start from next day
while (cursor <= endDate) {
const dow = cursor.getDay();
const dateStr = cursor.toISOString().slice(0, 10);
const month = cursor.getMonth();
if (dow === 0 && currentWeek.length > 0) {
weeks.push(currentWeek);
currentWeek = [];
}
if (month !== lastMonth) {
monthLabels.push({ col: weeks.length, label: t(`month.${String(month + 1).padStart(2, "0")}`) });
lastMonth = month;
}
currentWeek.push({ date: dateStr, size: map.get(dateStr) || 0, dow });
cursor.setDate(cursor.getDate() + 1);
}
if (currentWeek.length > 0) weeks.push(currentWeek);
return { weeks, monthLabels, thresholds: th };
}, [data, t]);
const totalSize = data.reduce((s, d) => s + d.size, 0);
const totalDays = data.filter((d) => d.size > 0).length;
const dayLabels = ["", "Mon", "", "Wed", "", "Fri", ""];
const labelWidth = 28;
const svgW = labelWidth + weeks.length * STEP + 10;
const svgH = 7 * STEP + 28; // extra for month labels
return (
<div style={{ overflowX: "auto", position: "relative" }}>
<div style={{ display: "flex", gap: 16, marginBottom: 8, fontSize: 12, color: isDark ? "#8b949e" : "#7c6a58" }}>
<span><strong style={{ color: isDark ? "#eeffff" : "#1c1410" }}>{totalDays}</strong> {t("heatmap.activeDays")}</span>
<span><strong style={{ color: isDark ? "#eeffff" : "#1c1410" }}>{totalSize < 1024 * 1024 ? `${(totalSize / 1024).toFixed(1)} KB` : `${(totalSize / 1024 / 1024).toFixed(2)} MB`}</strong> {t("heatmap.total")}</span>
{totalDays > 0 && <span>~<strong style={{ color: isDark ? "#eeffff" : "#1c1410" }}>{formatSize(Math.round(totalSize / totalDays))}</strong>{t("heatmap.perDay")}</span>}
</div>
<svg width={svgW} height={svgH} style={{ display: "block" }}>
{/* Month labels */}
{monthLabels.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * STEP}
y={10}
fontSize={10}
fill={isDark ? "#8b949e" : "#57606a"}
>
{m.label}
</text>
))}
{/* Day labels */}
{dayLabels.map((label, i) =>
label ? (
<text
key={i}
x={0}
y={18 + i * STEP + CELL / 2 + 3}
fontSize={9}
fill={isDark ? "#8b949e" : "#57606a"}
>
{label}
</text>
) : null
)}
{/* Cells */}
{weeks.map((week, wi) =>
week.map((day) => {
const level = getLevel(day.size, thresholds);
const x = labelWidth + wi * STEP;
const y = 18 + day.dow * STEP;
return (
<rect
key={day.date}
x={x}
y={y}
width={CELL}
height={CELL}
rx={2}
fill={colors[level]}
style={{ cursor: day.size > 0 ? "pointer" : "default" }}
onMouseEnter={(e) => {
const rect = (e.target as SVGRectElement).getBoundingClientRect();
setTooltip({
x: rect.left + CELL / 2,
y: rect.top - 8,
text: `${day.date} · ${day.size > 0 ? formatSize(day.size) : t("heatmap.noData")}`,
});
}}
onMouseLeave={() => setTooltip(null)}
onClick={() => {
if (day.size > 0) onOpenFile(`memory/${day.date}.md`);
}}
/>
);
})
)}
</svg>
{/* Legend */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
marginTop: 4,
fontSize: 11,
color: isDark ? "#8b949e" : "#57606a",
}}
>
<span>{t("heatmap.less")}</span>
{colors.map((c, i) => (
<span
key={i}
style={{
display: "inline-block",
width: CELL,
height: CELL,
borderRadius: 2,
background: c,
}}
/>
))}
<span>{t("heatmap.more")}</span>
</div>
{/* Tooltip */}
{tooltip && (
<div
style={{
position: "fixed",
left: tooltip.x,
top: tooltip.y,
transform: "translate(-50%, -100%)",
background: isDark ? "#1b1f23" : "#fff",
color: isDark ? "#e6edf3" : "#24292f",
border: `1px solid ${isDark ? "#30363d" : "#d0d7de"}`,
borderRadius: 6,
padding: "4px 8px",
fontSize: 12,
whiteSpace: "nowrap",
pointerEvents: "none",
zIndex: 10,
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
}}
>
{tooltip.text}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,486 @@
import { useEffect, useState } from "react";
import {
Clock,
Play,
Pause,
Lightning,
CaretRight,
ArrowsClockwise,
CheckCircle,
XCircle,
Timer,
Heartbeat,
Gear,
X,
} from "@phosphor-icons/react";
import {
fetchCronJobs,
fetchCronRuns,
fetchSystemCrons,
toggleCronJob,
runCronJob,
type CronJob,
type CronRun,
type SystemCron,
} from "../api";
import { useLocale } from "../hooks/useLocale";
type Tab = "user" | "system";
export function CronManager() {
const { t } = useLocale();
const [tab, setTab] = useState<Tab>("user");
const [jobs, setJobs] = useState<CronJob[]>([]);
const [systemCrons, setSystemCrons] = useState<SystemCron[]>([]);
const [loading, setLoading] = useState(true);
const [selectedJob, setSelectedJob] = useState<string | null>(null);
const [runs, setRuns] = useState<CronRun[]>([]);
const [runsLoading, setRunsLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: "ok" | "error" } | null>(null);
const showToast = (message: string, type: "ok" | "error" = "ok") => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const loadJobs = async () => {
try {
const data = await fetchCronJobs();
setJobs(data);
} catch (e) {
console.error("Failed to load cron jobs:", e);
} finally {
setLoading(false);
}
};
const loadSystemCrons = async () => {
try {
const data = await fetchSystemCrons();
setSystemCrons(data);
} catch (e) {
console.error("Failed to load system crons:", e);
}
};
useEffect(() => {
loadJobs();
loadSystemCrons();
const interval = setInterval(loadJobs, 30000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!selectedJob) {
setRuns([]);
return;
}
(async () => {
setRunsLoading(true);
try {
const data = await fetchCronRuns(selectedJob);
setRuns(data);
} catch (e) {
console.error("Failed to load runs:", e);
} finally {
setRunsLoading(false);
}
})();
}, [selectedJob]);
const handleToggle = async (job: CronJob) => {
setActionLoading(job.id);
try {
await toggleCronJob(job.id, !job.enabled);
showToast(`${!job.enabled ? "✅ 已启用" : "⏸ 已禁用"} ${job.name}`);
await loadJobs();
} catch (e: any) {
showToast(`${e.message || "操作失败"}`, "error");
} finally {
setActionLoading(null);
}
};
const handleRun = async (job: CronJob) => {
setActionLoading(`run-${job.id}`);
try {
const result = await runCronJob(job.id);
if (result.success) {
showToast(`${job.name} 已触发运行`);
setTimeout(loadJobs, 2000);
} else {
showToast(`${result.error || "运行失败"}`, "error");
}
} catch (e: any) {
showToast(`${e.message || "请求失败"}`, "error");
} finally {
setActionLoading(null);
}
};
const enabledCount = jobs.filter((j) => j.enabled).length;
const disabledCount = jobs.length - enabledCount;
function formatRelativeTime(iso: string | null): string {
if (!iso) return "-";
const date = new Date(iso);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
const absDiff = Math.abs(diffMs);
const isPast = diffMs < 0;
if (absDiff < 60000) return isPast ? t("cron.justNow") : t("cron.soonLabel");
if (absDiff < 3600000) {
const mins = Math.floor(absDiff / 60000);
return isPast ? `${mins}${t("cron.minAgo")}` : `${mins}${t("cron.minLater")}`;
}
if (absDiff < 86400000) {
const hours = Math.floor(absDiff / 3600000);
return isPast ? `${hours}${t("cron.hAgo")}` : `${hours}${t("cron.hLater")}`;
}
const days = Math.floor(absDiff / 86400000);
return isPast ? `${days}${t("cron.dAgo")}` : `${days}${t("cron.dLater")}`;
}
function statusColor(status: string | null): string {
if (!status) return "var(--text-muted)";
if (status === "ok" || status === "completed") return "#22c55e";
if (status === "failed" || status === "error") return "#ef4444";
return "#eab308";
}
if (loading) {
return (
<div className="flex items-center justify-center h-full" style={{ color: "var(--text-muted)" }}>
<ArrowsClockwise className="w-5 h-5 animate-spin mr-2" />
{t("cron.loading")}
</div>
);
}
return (
<div className="h-full overflow-auto relative">
{/* Toast */}
{toast && (
<div
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium animate-[fadeIn_0.2s_ease-out]"
style={{
background: toast.type === "ok" ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.15)",
color: toast.type === "ok" ? "#22c55e" : "#ef4444",
border: `1px solid ${toast.type === "ok" ? "rgba(34,197,94,0.3)" : "rgba(239,68,68,0.3)"}`,
backdropFilter: "blur(12px)",
}}
>
{toast.message}
</div>
)}
<div className="max-w-5xl mx-auto px-4 py-6 sm:px-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<Clock className="w-5 h-5 text-indigo-400" />
{t("cron.title")}
</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
{t("cron.subtitle")}
</p>
</div>
<button
onClick={() => { loadJobs(); loadSystemCrons(); }}
className="p-2 rounded-lg transition-colors hover:bg-white/10"
style={{ color: "var(--text-muted)" }}
title={t("cron.refresh")}
>
<ArrowsClockwise className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-5 p-1 rounded-lg" style={{ background: "var(--bg-secondary)" }}>
<button
onClick={() => setTab("user")}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium transition-colors"
style={{
background: tab === "user" ? "var(--bg-hover)" : "transparent",
color: tab === "user" ? "var(--text-primary)" : "var(--text-muted)",
}}
>
<Clock className="w-4 h-4" />
<span
className="ml-1 text-xs px-1.5 py-0.5 rounded-full"
style={{ background: tab === "user" ? "var(--link)" : "var(--bg-hover)", color: tab === "user" ? "#fff" : "var(--text-muted)" }}
>
{jobs.length}
</span>
</button>
<button
onClick={() => setTab("system")}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium transition-colors"
style={{
background: tab === "system" ? "var(--bg-hover)" : "transparent",
color: tab === "system" ? "var(--text-primary)" : "var(--text-muted)",
}}
>
<Gear className="w-4 h-4" />
<span
className="ml-1 text-xs px-1.5 py-0.5 rounded-full"
style={{ background: tab === "system" ? "var(--link)" : "var(--bg-hover)", color: tab === "system" ? "#fff" : "var(--text-muted)" }}
>
{systemCrons.length}
</span>
</button>
</div>
{tab === "user" && (
<>
{/* Stats */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="rounded-lg p-3" style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}>
<div className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>{jobs.length}</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>{t("cron.total")}</div>
</div>
<div className="rounded-lg p-3" style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}>
<div className="text-2xl font-bold text-green-400">{enabledCount}</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>{t("cron.enabled")}</div>
</div>
<div className="rounded-lg p-3" style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}>
<div className="text-2xl font-bold" style={{ color: "var(--text-muted)" }}>{disabledCount}</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>{t("cron.disabled")}</div>
</div>
</div>
{/* Job List */}
<div className="space-y-2">
{jobs.map((job) => (
<div
key={job.id}
className="rounded-lg transition-colors"
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
>
<div className="flex items-center gap-3 px-4 py-3">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ background: job.enabled ? "#22c55e" : "var(--text-muted)" }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate" style={{ color: "var(--text-primary)" }}>
{job.name}
</span>
<code
className="text-[10px] px-1.5 py-0.5 rounded shrink-0"
style={{ background: "var(--bg-hover)", color: "var(--text-muted)" }}
>
{job.schedule}
</code>
</div>
<div className="flex items-center gap-3 mt-1 text-xs" style={{ color: "var(--text-muted)" }}>
{job.lastRun && (
<span className="flex items-center gap-1">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: statusColor(job.lastStatus) }}
/>
{formatRelativeTime(job.lastRun)}
</span>
)}
{job.nextRun && job.enabled && (
<span className="flex items-center gap-1">
<Timer className="w-3 h-3" />
{formatRelativeTime(job.nextRun)}
</span>
)}
<span>{job.sessionTarget}</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleToggle(job)}
disabled={actionLoading === job.id}
className="p-1.5 rounded-md transition-colors hover:bg-white/10"
style={{ color: actionLoading === job.id ? "var(--text-muted)" : job.enabled ? "#eab308" : "#22c55e" }}
title={job.enabled ? t("cron.disable") : t("cron.enable")}
>
{actionLoading === job.id ? (
<ArrowsClockwise className="w-4 h-4 animate-spin" />
) : job.enabled ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button>
<button
onClick={() => handleRun(job)}
disabled={actionLoading === `run-${job.id}`}
className="p-1.5 rounded-md transition-colors hover:bg-white/10"
style={{ color: actionLoading === `run-${job.id}` ? "var(--text-muted)" : "var(--link)" }}
title={t("cron.runNow")}
>
{actionLoading === `run-${job.id}` ? (
<ArrowsClockwise className="w-4 h-4 animate-spin" />
) : (
<Lightning className="w-4 h-4" />
)}
</button>
<button
onClick={() => setSelectedJob(selectedJob === job.id ? null : job.id)}
className="p-1.5 rounded-md transition-colors hover:bg-white/10"
style={{ color: selectedJob === job.id ? "var(--link)" : "var(--text-muted)" }}
title={t("cron.history")}
>
<CaretRight
className="w-4 h-4 transition-transform"
style={{ transform: selectedJob === job.id ? "rotate(90deg)" : undefined }}
/>
</button>
</div>
</div>
{/* Run History */}
{selectedJob === job.id && (
<div className="px-4 pb-3 border-t" style={{ borderColor: "var(--border)" }}>
<div className="pt-3 text-xs font-medium mb-2" style={{ color: "var(--text-muted)" }}>
{t("cron.runHistory")}
</div>
{runsLoading ? (
<div className="flex items-center gap-2 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
<ArrowsClockwise className="w-3 h-3 animate-spin" />
{t("cron.loading")}
</div>
) : runs.length === 0 ? (
<div className="py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{t("cron.noRuns")}
</div>
) : (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{runs.map((run, i) => (
<div
key={i}
className="flex items-center justify-between py-1.5 px-2 rounded"
style={{ background: "var(--bg-hover)" }}
>
<div className="flex items-center gap-2">
{run.status === "completed" || run.status === "ok" ? (
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
) : run.status === "failed" || run.status === "error" ? (
<XCircle className="w-3.5 h-3.5 text-red-400" />
) : (
<Clock className="w-3.5 h-3.5 text-yellow-400" />
)}
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
{run.status || "unknown"}
</span>
</div>
<div className="flex items-center gap-3 text-[10px]" style={{ color: "var(--text-muted)" }}>
{run.durationMs != null && <span>{(run.durationMs / 1000).toFixed(1)}s</span>}
<span>
{(() => {
const ts = run.runAtMs || run.ts || run.startedAt;
if (!ts) return "—";
const d = new Date(typeof ts === "number" ? ts : ts);
return isNaN(d.getTime()) ? "—" : d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
})()}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
{jobs.length === 0 && (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
<Clock className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>{t("cron.empty")}</p>
</div>
)}
</>
)}
{tab === "system" && (
<div className="space-y-2">
{systemCrons.map((sc) => (
<div
key={sc.id}
className="rounded-lg"
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
>
<div className="flex items-center gap-3 px-4 py-3">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ background: sc.enabled ? "#22c55e" : "var(--text-muted)" }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
{sc.name}
</span>
<code
className="text-[10px] px-1.5 py-0.5 rounded shrink-0"
style={{ background: "var(--bg-hover)", color: "var(--text-muted)" }}
>
{sc.schedule}
</code>
</div>
<div className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{sc.description}
</div>
</div>
<span
className="text-[10px] px-2 py-0.5 rounded-full shrink-0"
style={{
background: sc.enabled ? "rgba(34,197,94,0.1)" : "var(--bg-hover)",
color: sc.enabled ? "#22c55e" : "var(--text-muted)",
}}
>
{sc.enabled ? "运行中" : "禁用"}
</span>
</div>
{/* Heartbeat agent details */}
{sc.type === "heartbeat" && sc.agents && (
<div className="px-4 pb-3 border-t" style={{ borderColor: "var(--border)" }}>
<div className="pt-2 space-y-1">
{sc.agents.map((agent) => (
<div
key={agent.id}
className="flex items-center justify-between py-1.5 px-2 rounded text-xs"
style={{ background: "var(--bg-hover)" }}
>
<span style={{ color: "var(--text-secondary)" }}>
{agent.name}
<span className="ml-1" style={{ color: "var(--text-muted)" }}>({agent.id})</span>
</span>
<span style={{ color: agent.enabled ? "#22c55e" : "var(--text-muted)" }}>
{agent.heartbeat === "disabled" ? "禁用" : agent.heartbeat}
</span>
</div>
))}
</div>
</div>
)}
</div>
))}
{systemCrons.length === 0 && (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
<Gear className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p></p>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import { useEffect, useState } from "react";
import { fetchSystem, fetchRecent, fetchDailyStats, fetchAgents, type SystemInfo, type RecentFile, type DailyStats, type AgentInfo } from "../api";
import { ContributionHeatmap } from "./ContributionHeatmap";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { SquaresFour, FileText, Clock, ChartBar, Lightning, Robot, Folder } from "@phosphor-icons/react";
import { useLocale } from "../hooks/useLocale";
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (d > 0) parts.push(`${d}d`);
parts.push(`${h}h ${m}m`);
return parts.join(" ");
}
function formatBytes(bytes: number): string {
return (bytes / 1024 / 1024 / 1024).toFixed(1) + " GB";
}
function timeAgo(mtime: number, t: (key: string) => string): string {
const diff = Date.now() - mtime;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return t("dashboard.justNow");
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} ${t("dashboard.minAgo")}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}${t("dashboard.hAgo")}`;
const days = Math.floor(hours / 24);
return `${days}${t("dashboard.dAgo")}`;
}
const PINNED_FILES = ["MEMORY.md", "SOUL.md", "USER.md", "AGENTS.md"];
export function Dashboard({ onOpenFile }: { onOpenFile: (path: string) => void }) {
const { t } = useLocale();
const [info, setInfo] = useState<SystemInfo | null>(null);
const [recent, setRecent] = useState<RecentFile[]>([]);
const [daily, setDaily] = useState<DailyStats[]>([]);
const [agents, setAgents] = useState<AgentInfo[]>([]);
useEffect(() => {
fetchSystem().then(setInfo).catch(console.error);
fetchRecent(10).then(setRecent).catch(console.error);
fetchDailyStats().then(setDaily).catch(console.error);
fetchAgents().then(setAgents).catch(console.error);
}, []);
if (!info) {
return (
<div className="flex items-center justify-center h-full" style={{ color: "var(--text-faint)" }}>
<div className="w-5 h-5 border-2 border-t-blue-400 rounded-full animate-spin mr-3" style={{ borderColor: "var(--border)" }} />
{t("dashboard.loading")}
</div>
);
}
const memPercent = ((info.memUsed / info.memTotal) * 100).toFixed(1);
// Quick access: pinned + recent files not in pinned
const recentQuick = recent
.map((r) => r.path)
.filter((p) => !PINNED_FILES.includes(p))
.slice(0, 4);
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6">
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<SquaresFour className="w-7 h-7 text-blue-400" /> {t("dashboard.title")}
</h1>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label={t("dashboard.uptime")} value={formatUptime(info.uptime)} />
<StatCard label={t("dashboard.memory")} value={`${memPercent}%`} sub={`${formatBytes(info.memUsed)} / ${formatBytes(info.memTotal)}`} />
<StatCard label={t("dashboard.load")} value={info.load[0].toFixed(2)} sub={info.load.map((l) => l.toFixed(2)).join(" · ")} />
<StatCard label={t("dashboard.files")} value={String(info.totalFiles)} sub={t("dashboard.mdTracked")} />
</div>
{/* Host info */}
<div className="text-sm flex items-center gap-2" style={{ color: "var(--text-faint)" }}>
<span className="inline-block w-2 h-2 rounded-full bg-emerald-400" />
{info.hostname} · {info.platform}
</div>
{/* Agents Overview */}
{agents.length > 1 && (
<section className="rounded-xl p-5" style={{ background: "var(--bg-tertiary)", border: "1px solid var(--border)" }}>
<h2 className="text-lg font-semibold flex items-center gap-2 mb-4" style={{ color: "var(--text-primary)" }}>
<Robot className="w-5 h-5 text-blue-400" /> Agents
<span className="text-sm font-normal opacity-60 ml-2">{agents.length} configured</span>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{agents.map((agent) => (
<div
key={agent.id}
className="rounded-lg p-3 flex items-center gap-3"
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
>
<span className="text-2xl">{agent.emoji}</span>
<div className="flex-1 min-w-0">
<div className="font-medium truncate" style={{ color: "var(--text-primary)" }}>
{agent.name}
</div>
<div className="text-xs truncate flex items-center gap-1" style={{ color: "var(--text-faint)" }}>
<Folder className="w-3 h-3" />
{agent.workspace}
</div>
</div>
</div>
))}
</div>
</section>
)}
{/* Two-column: Recently Modified + Monthly Stats */}
<div className="grid grid-cols-1 gap-4">
{/* Recently Modified */}
<section className="rounded-xl p-5" style={{ background: "var(--bg-tertiary)", border: "1px solid var(--border)" }}>
<h2 className="text-lg font-semibold flex items-center gap-2 mb-3" style={{ color: "var(--text-primary)" }}>
<Clock className="w-5 h-5 text-amber-400" /> {t("dashboard.recentlyModified")}
</h2>
<div className="space-y-1">
{recent.slice(0, 5).map((f) => (
<button
key={f.path}
onClick={() => onOpenFile(f.path)}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors hover:bg-white/5"
style={{ color: "var(--text-secondary)" }}
>
<span className="truncate mr-2" title={f.path}>
<FileText className="w-3.5 h-3.5 inline-block mr-1.5 opacity-50" />
{f.path}
</span>
<span className="text-xs whitespace-nowrap shrink-0" style={{ color: "var(--text-faint)" }}>
{timeAgo(f.mtime, t)}
</span>
</button>
))}
{recent.length === 0 && (
<p className="text-sm italic" style={{ color: "var(--text-faint)" }}>{t("dashboard.noFiles")}</p>
)}
</div>
</section>
{/* Memory Activity Heatmap */}
<section className="rounded-xl p-5" style={{ background: "var(--bg-tertiary)", border: "1px solid var(--border)" }}>
<h2 className="text-lg font-semibold flex items-center gap-2 mb-3" style={{ color: "var(--text-primary)" }}>
<ChartBar className="w-5 h-5 text-purple-400" /> {t("dashboard.memoryByMonth")}
</h2>
{daily.length > 0 ? (
<ContributionHeatmap data={daily} onOpenFile={onOpenFile} />
) : (
<p className="text-sm italic" style={{ color: "var(--text-faint)" }}>{t("dashboard.noMemoryFiles")}</p>
)}
</section>
</div>
{/* Today's memory */}
<section className="rounded-xl p-5" style={{ background: "var(--bg-tertiary)", border: "1px solid var(--border)" }}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<FileText className="w-5 h-5" style={{ color: "var(--text-muted)" }} /> {t("dashboard.todayMemory")}
</h2>
{info.todayMemory && (
<button
onClick={() => onOpenFile(info.todayMemory!.filename)}
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
{t("dashboard.viewFull")}
</button>
)}
</div>
{info.todayMemory ? (
<div className="markdown-body text-sm opacity-80">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{info.todayMemory.snippet}
</ReactMarkdown>
<div className="text-xs mt-3" style={{ color: "var(--text-faint)" }}>
{info.todayMemory.length.toLocaleString()} {t("dashboard.characters")}
</div>
</div>
) : (
<p style={{ color: "var(--text-faint)" }} className="italic">{t("dashboard.noMemoryToday")}</p>
)}
</section>
{/* Quick Access */}
<section>
<h2 className="text-sm font-semibold flex items-center gap-1.5 mb-2" style={{ color: "var(--text-muted)" }}>
<Lightning className="w-4 h-4" /> {t("dashboard.quickAccess")}
</h2>
<div className="flex flex-wrap gap-2">
{PINNED_FILES.map((f) => (
<button
key={f}
onClick={() => onOpenFile(f)}
className="btn-secondary text-sm"
>
📌 {f}
</button>
))}
{recentQuick.map((f) => (
<button
key={f}
onClick={() => onOpenFile(f)}
className="btn-secondary text-sm opacity-75"
>
🕐 {f.split("/").pop()}
</button>
))}
</div>
</section>
</div>
);
}
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-xl p-4" style={{ background: "var(--bg-tertiary)", border: "1px solid var(--border)" }}>
<div className="text-xs uppercase tracking-wider mb-1" style={{ color: "var(--text-faint)" }}>{label}</div>
<div className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>{value}</div>
{sub && <div className="text-xs mt-0.5" style={{ color: "var(--text-faint)" }}>{sub}</div>}
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import type { FileNode } from "../api";
import { CaretDown, CaretRight, Folder, FileText, Brain, Dna, Robot, User, Wrench, ListChecks, Heartbeat, IdentificationCard, Gear, Calendar, Clock, CaretUp } from "@phosphor-icons/react";
import { useLocale } from "../hooks/useLocale";
/** Well-known bot config files shown in the top section */
const BOT_FILES = new Set([
"AGENTS.md", "SOUL.md", "MEMORY.md", "USER.md", "TOOLS.md",
"TODO.md", "HEARTBEAT.md", "IDENTITY.md", "BOOTSTRAP.md",
]);
function isBotFile(name: string): boolean {
return BOT_FILES.has(name);
}
/** Check if a path is a daily note (memory/YYYY-MM-DD*.md) */
function isDailyNote(path: string): boolean {
return /^memory\/\d{4}-\d{2}-\d{2}.*\.md$/.test(path);
}
/** Get display label for daily note */
function getDailyNoteLabel(path: string): { label: string; isToday: boolean; isYesterday: boolean } {
const match = path.match(/memory\/(\d{4}-\d{2}-\d{2})/);
if (!match) return { label: path, isToday: false, isYesterday: false };
const dateStr = match[1];
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
if (dateStr === today) return { label: "Today", isToday: true, isYesterday: false };
if (dateStr === yesterday) return { label: "Yesterday", isYesterday: true, isToday: false };
// Show date in a readable format for older notes
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
return { label: date.toLocaleDateString("en-US", options), isToday: false, isYesterday: false };
}
/** Local storage key for collapsed state */
const COLLAPSED_KEY = "memory-viewer-collapsed";
const SECTIONS_KEY = "memory-viewer-sections";
interface SectionState {
coreFiles: boolean;
files: boolean;
}
function loadCollapsedState(): Set<string> {
try {
const stored = localStorage.getItem(COLLAPSED_KEY);
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
}
function saveCollapsedState(collapsed: Set<string>) {
try {
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsed]));
} catch {
// Ignore storage errors
}
}
function loadSectionState(): SectionState {
try {
const stored = localStorage.getItem(SECTIONS_KEY);
return stored ? JSON.parse(stored) : { coreFiles: false, files: true };
} catch {
return { coreFiles: false, files: true };
}
}
function saveSectionState(state: SectionState) {
try {
localStorage.setItem(SECTIONS_KEY, JSON.stringify(state));
} catch {
// Ignore storage errors
}
}
interface FileTreeProps {
nodes: FileNode[];
activeFile: string;
onSelect: (path: string) => void;
}
export function FileTree({ nodes, activeFile, onSelect }: FileTreeProps) {
const { t } = useLocale();
const [collapsed, setCollapsed] = useState<Set<string>>(() => loadCollapsedState());
const [sections, setSections] = useState<SectionState>(() => loadSectionState());
// Persist collapsed state
useEffect(() => {
saveCollapsedState(collapsed);
}, [collapsed]);
// Persist section state
useEffect(() => {
saveSectionState(sections);
}, [sections]);
const toggleSection = (key: keyof SectionState) => {
setSections(prev => ({ ...prev, [key]: !prev[key] }));
};
const toggleCollapsed = useCallback((path: string) => {
setCollapsed(prev => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const { botFiles, otherNodes } = useMemo(() => {
const bot: FileNode[] = [];
const other: FileNode[] = [];
for (const node of nodes) {
if (node.type === "file" && isBotFile(node.name)) {
bot.push(node);
} else {
other.push(node);
}
}
const order = [...BOT_FILES];
bot.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
other.sort((a, b) => {
if (a.type === "dir" && b.type !== "dir") return -1;
if (a.type !== "dir" && b.type === "dir") return 1;
return a.name.localeCompare(b.name);
});
return { botFiles: bot, otherNodes: other };
}, [nodes]);
return (
<nav className="text-sm" aria-label="File tree">
{/* Bot Config Files - Collapsible */}
{botFiles.length > 0 && (
<div className="mb-2">
<button
onClick={() => toggleSection("coreFiles")}
className="w-full flex items-center justify-between px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider transition-colors hover:text-blue-400"
style={{ color: "var(--text-muted)" }}
>
{t("sidebar.coreFiles") || "Core Files"}
{sections.coreFiles ? <CaretDown className="w-3 h-3" /> : <CaretRight className="w-3 h-3" />}
</button>
{sections.coreFiles && (
<div className="flex flex-col">
{botFiles.map((node) => (
<TreeNode key={node.path} node={node} activeFile={activeFile} onSelect={onSelect} depth={0} collapsed={collapsed} onToggle={toggleCollapsed} />
))}
</div>
)}
</div>
)}
{/* Files - Collapsible */}
{otherNodes.length > 0 && (
<div>
<button
onClick={() => toggleSection("files")}
className="w-full flex items-center justify-between px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider transition-colors hover:text-blue-400"
style={{ color: "var(--text-muted)" }}
>
{t("sidebar.files")}
{sections.files ? <CaretDown className="w-3 h-3" /> : <CaretRight className="w-3 h-3" />}
</button>
{sections.files && (
<div className="flex flex-col">
{otherNodes.map((node) => (
<TreeNode key={node.path} node={node} activeFile={activeFile} onSelect={onSelect} depth={0} collapsed={collapsed} onToggle={toggleCollapsed} />
))}
</div>
)}
</div>
)}
</nav>
);
}
function TreeNode({ node, activeFile, onSelect, depth, collapsed, onToggle }: {
node: FileNode;
activeFile: string;
onSelect: (path: string) => void;
depth: number;
collapsed: Set<string>;
onToggle: (path: string) => void;
}) {
const indent = depth * 12 + 8;
const isCollapsed = collapsed.has(node.path);
// Default: all directories are collapsed unless explicitly expanded
const isOpen = !collapsed.has(node.path);
if (node.type === "dir") {
return (
<div>
<button
onClick={() => onToggle(node.path)}
className="sidebar-item flex items-center gap-1.5 w-full py-1.5 rounded-md"
style={{ paddingLeft: `${indent}px` }}
>
{!isCollapsed ? <CaretDown className="w-3 h-3 opacity-60 shrink-0" /> : <CaretRight className="w-3 h-3 opacity-60 shrink-0" />}
<Folder className={`w-3.5 h-3.5 shrink-0 ${!isCollapsed ? "text-amber-400" : "text-amber-400/60"}`} />
<span className="truncate">{node.name}</span>
{node.children && (
<span className="text-[10px] ml-auto mr-2" style={{ color: "var(--text-faint)" }}>{node.children.length}</span>
)}
</button>
{!isCollapsed && node.children?.slice().sort((a, b) => {
if (a.type === "dir" && b.type !== "dir") return -1;
if (a.type !== "dir" && b.type === "dir") return 1;
return a.name.localeCompare(b.name);
}).map((child) => (
<TreeNode key={child.path} node={child} activeFile={activeFile} onSelect={onSelect} depth={depth + 1} collapsed={collapsed} onToggle={onToggle} />
))}
</div>
);
}
const isActive = activeFile === node.path;
return (
<button
onClick={() => onSelect(node.path)}
className={`flex items-center gap-1.5 w-full py-1.5 rounded-md transition-colors ${
isActive ? "sidebar-item-active font-medium" : "sidebar-item"
}`}
style={{ paddingLeft: `${indent}px` }}
>
<span className="opacity-70 shrink-0">
{node.name === "MEMORY.md" ? <Brain className="w-3.5 h-3.5 text-purple-400" /> :
node.name === "SOUL.md" ? <Dna className="w-3.5 h-3.5 text-emerald-400" /> :
node.name === "AGENTS.md" ? <Robot className="w-3.5 h-3.5 text-blue-400" /> :
node.name === "USER.md" ? <User className="w-3.5 h-3.5 text-amber-400" /> :
node.name === "TOOLS.md" ? <Wrench className="w-3.5 h-3.5 text-gray-400" /> :
node.name === "TODO.md" ? <ListChecks className="w-3.5 h-3.5 text-orange-400" /> :
node.name === "HEARTBEAT.md" ? <Heartbeat className="w-3.5 h-3.5 text-red-400" /> :
node.name === "IDENTITY.md" ? <IdentificationCard className="w-3.5 h-3.5 text-cyan-400" /> :
node.name === "BOOTSTRAP.md" ? <Gear className="w-3.5 h-3.5 text-gray-400" /> :
<FileText className="w-3.5 h-3.5 text-gray-500" />}
</span>
<span className="truncate">{node.name}</span>
</button>
);
}

View File

@@ -0,0 +1,708 @@
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkFrontmatter from "remark-frontmatter";
import rehypeRaw from "rehype-raw";
import { fetchFile, saveFile, resolveWikilink, ConflictResult } from "../api";
import { PencilSimple, FloppyDisk, X, Check, WarningCircle, CaretRight, ArrowUp, Copy, Warning, ArrowsClockwise, PenNib, Cube } from "@phosphor-icons/react";
import { PluginSlot } from "../plugins/PluginSlot";
import { SensitiveText } from "./SensitiveMask";
import { useLocale } from "../hooks/useLocale";
import { lazy, Suspense } from "react";
import { renderMermaid, THEMES } from "beautiful-mermaid";
import { useMarkdownTheme } from "../themes";
import { applyThemeStyles, cleanInlineStyles } from "../themes/apply";
import mermaid from "mermaid";
const MarkdownEditor = lazy(() => import("./MarkdownEditor").then(m => ({ default: m.MarkdownEditor })));
// Initialize mermaid for different styles
function initMermaid(isDark: boolean, handDrawn: boolean) {
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
theme: isDark ? "dark" : "default",
look: handDrawn ? "handDrawn" : "classic",
fontFamily: handDrawn
? "Virgil, Segoe Print, Bradley Hand, Chilanka, TSCu_Comic, casual, cursive"
: "Inter, sans-serif",
});
}
type MermaidStyle = "normal" | "handDrawn";
/** Mermaid diagram renderer with style toggle */
function MermaidBlock({ code }: { code: string }) {
const [svg, setSvg] = useState<string>("");
const [error, setError] = useState<string>("");
const [style, setStyle] = useState<MermaidStyle>("normal");
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const isDark = document.documentElement.classList.contains("dark");
// Generate unique ID for each render to avoid conflicts
const renderId = `mermaid-${Math.random().toString(36).slice(2)}`;
if (style === "handDrawn") {
// Use native mermaid with hand-drawn look
initMermaid(isDark, true);
console.log("[Mermaid] Rendering hand-drawn style with ID:", renderId);
mermaid.render(renderId, code)
.then((result) => {
console.log("[Mermaid] Render success, svg length:", result.svg?.length);
setSvg(result.svg);
setError("");
})
.catch((e) => {
console.error("[Mermaid] Render failed:", e);
setError(e.message || "Failed to render diagram");
setSvg("");
});
} else {
// Use beautiful-mermaid for normal style
const baseTheme = isDark ? THEMES["github-dark"] : THEMES["github-light"];
const theme = { ...baseTheme, bg: "transparent" };
renderMermaid(code, theme)
.then((result) => {
setSvg(typeof result === "string" ? result : (result as any).svg || String(result));
setError("");
})
.catch((e) => {
setError(e.message || "Failed to render diagram");
setSvg("");
});
}
}, [code, style]);
if (error) {
return (
<div className="p-4 rounded-xl text-sm" style={{ background: "var(--pre-bg)", border: "1px solid var(--pre-border)", color: "var(--text-muted)" }}>
<div className="mb-2 text-red-400"> Mermaid render error: {error}</div>
<pre style={{ whiteSpace: "pre-wrap", color: "var(--pre-text)" }}>{code}</pre>
</div>
);
}
if (!svg) {
return <div className="p-4 text-sm" style={{ color: "var(--text-faint)" }}>Rendering diagram...</div>;
}
return (
<div className="relative my-4">
{/* Style toggle button */}
<div className="absolute top-2 right-2 z-10 flex gap-1">
<button
onClick={() => setStyle("normal")}
className={`p-1.5 rounded-md transition-colors ${
style === "normal"
? "bg-blue-500/20 text-blue-400"
: "bg-gray-500/10 text-gray-400 hover:bg-gray-500/20"
}`}
title="Normal style"
>
<Cube size={14} />
</button>
<button
onClick={() => setStyle("handDrawn")}
className={`p-1.5 rounded-md transition-colors ${
style === "handDrawn"
? "bg-blue-500/20 text-blue-400"
: "bg-gray-500/10 text-gray-400 hover:bg-gray-500/20"
}`}
title="Hand-drawn style"
>
<PenNib size={14} />
</button>
</div>
<div
ref={containerRef}
className="overflow-x-auto flex justify-center"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
);
}
/** Extract YAML front matter and return { meta, body } */
interface FrontMatterResult {
meta: Record<string, string> | null;
metadata: Record<string, any> | null;
body: string;
}
function parseFrontMatter(raw: string): FrontMatterResult {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { meta: null, metadata: null, body: raw };
const yamlBlock = match[1];
const body = match[2];
const meta: Record<string, string> = {};
let metadata: Record<string, any> | null = null;
for (const line of yamlBlock.split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) {
const key = line.slice(0, idx).trim();
const val = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, "");
if (!key || !val) continue;
if (key === "metadata") {
try { metadata = JSON.parse(val); } catch { meta[key] = val; }
} else {
meta[key] = val;
}
}
}
return { meta: Object.keys(meta).length ? meta : null, metadata, body };
}
/** Recursively wrap string children with SensitiveText and WikiLinks */
function maskChildren(children: React.ReactNode, onOpenFile?: (path: string) => void): React.ReactNode {
if (typeof children === "string") {
// Check for wikilinks first
if (children.includes("[[")) {
const parts = processWikiLinks(children, onOpenFile);
return parts.map((part, i) =>
typeof part === "string" ? <SensitiveText key={i}>{part}</SensitiveText> : part
);
}
return <SensitiveText>{children}</SensitiveText>;
}
if (Array.isArray(children)) return children.map((c, i) =>
typeof c === "string" ? (
c.includes("[[") ? (
<span key={i}>{processWikiLinks(c, onOpenFile).map((part, j) =>
typeof part === "string" ? <SensitiveText key={j}>{part}</SensitiveText> : part
)}</span>
) : <SensitiveText key={i}>{c}</SensitiveText>
) : c
);
return children;
}
/** Shiki highlighter singleton */
import { createHighlighter, type Highlighter } from "shiki";
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ["github-dark", "github-light"],
langs: ["bash", "javascript", "typescript", "python", "json", "yaml", "markdown", "css", "html", "go", "rust", "sql", "diff", "dockerfile", "toml", "ini", "tsx", "jsx", "java", "c", "cpp", "shell", "ruby", "php", "swift", "kotlin"],
});
}
return highlighterPromise;
}
/** Code block with copy button */
function CodeBlock({ className, children, ...props }: any) {
const { t } = useLocale();
const [copied, setCopied] = useState(false);
const [html, setHtml] = useState<string>("");
const match = /language-(\w+)/.exec(className || "");
const text = String(children).replace(/\n$/, "");
const isBlock = !!match || text.includes("\n");
const isDark = document.documentElement.classList.contains("dark");
useEffect(() => {
if (!isBlock || !match) return;
if (match[1] === "mermaid") return;
let cancelled = false;
getHighlighter().then((hl) => {
if (cancelled) return;
const lang = hl.getLoadedLanguages().includes(match[1] as any) ? match[1] : "text";
const result = hl.codeToHtml(text, {
lang,
theme: isDark ? "github-dark" : "github-light",
});
setHtml(result);
});
return () => { cancelled = true; };
}, [text, match?.[1], isDark, isBlock]);
if (!isBlock) {
if (typeof children === "string") {
return <code {...props}><SensitiveText>{children}</SensitiveText></code>;
}
return <code {...props}>{children}</code>;
}
// Mermaid diagrams
if (match && match[1] === "mermaid") {
return <MermaidBlock code={text} />;
}
// Plain text code blocks (no language) - use simple <pre> for uniform background
if (!match) {
return (
<div className="relative group">
<button
className="code-copy-btn"
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
title={t("file.copy")}
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
</button>
<pre style={{
margin: 0,
borderRadius: "0.75rem",
fontSize: "0.85rem",
lineHeight: "1.7",
border: `1px solid var(--pre-border)`,
padding: "1rem",
background: "var(--pre-bg)",
color: "var(--pre-text)",
fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
overflowX: "auto",
whiteSpace: "pre",
}}>
<code style={{ background: "transparent", fontFamily: "inherit" }}>{text}</code>
</pre>
</div>
);
}
return (
<div className="relative group">
<button
className="code-copy-btn"
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
title={t("file.copy")}
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
</button>
{html ? (
<div className="shiki-wrapper" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<pre style={{
margin: 0, borderRadius: "0.75rem", fontSize: "0.85rem", lineHeight: "1.7",
border: "1px solid var(--pre-border)", padding: "1rem",
background: "var(--pre-bg)", color: "var(--pre-text)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", overflowX: "auto", whiteSpace: "pre",
}}>
<code>{text}</code>
</pre>
)}
</div>
);
}
/** Breadcrumb path */
function Breadcrumb({ path, hasChanges, onNavigate }: { path: string; hasChanges: boolean; onNavigate?: (dir: string) => void }) {
const parts = path.split("/");
return (
<span className={`font-medium flex items-center flex-wrap gap-0.5 min-w-0 ${hasChanges ? "text-yellow-400" : ""}`} style={hasChanges ? {} : { color: "var(--text-primary)" }}>
{hasChanges && "● "}
{parts.map((part, i) => {
const isLast = i === parts.length - 1;
const dir = parts.slice(0, i + 1).join("/");
return (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && <CaretRight className="w-3 h-3 shrink-0" style={{ color: "var(--text-faint)" }} />}
{isLast ? (
<span className="truncate">{part}</span>
) : (
<button
className="hover:text-blue-400 transition-colors truncate"
onClick={() => onNavigate?.(dir)}
>
{part}
</button>
)}
</span>
);
})}
</span>
);
}
/** WikiLink component: renders [[xxx]] as clickable link */
function WikiLink({ target, onOpenFile }: { target: string; onOpenFile?: (path: string) => void }) {
const [resolved, setResolved] = useState<{ found: boolean; path: string | null } | null>(null);
useEffect(() => {
resolveWikilink(target).then(setResolved).catch(() => setResolved({ found: false, path: null }));
}, [target]);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
if (resolved?.found && resolved.path && onOpenFile) {
onOpenFile(resolved.path);
}
};
if (!resolved) return <span style={{ color: "var(--text-muted)" }}>[[{target}]]</span>;
if (resolved.found) {
return (
<a
href="#"
onClick={handleClick}
className="wikilink"
style={{ color: "#3b82f6", textDecoration: "underline", cursor: "pointer" }}
>
{target}
</a>
);
}
return (
<span
className="wikilink-broken"
style={{ color: "#ef4444", textDecoration: "underline", textDecorationStyle: "dashed", cursor: "default" }}
title="File not found"
>
{target}
</span>
);
}
/** Process text to replace [[xxx]] with WikiLink components */
function processWikiLinks(text: string, onOpenFile?: (path: string) => void): React.ReactNode[] {
const parts: React.ReactNode[] = [];
const regex = /\[\[([^\]]+)\]\]/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
parts.push(<WikiLink key={match.index} target={match[1].trim()} onOpenFile={onOpenFile} />);
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
interface FileViewerProps {
filePath: string;
refreshKey?: number;
onNavigate?: (dir: string) => void;
onOpenFile?: (path: string) => void;
}
export function FileViewer({ filePath, refreshKey, onNavigate, onOpenFile }: FileViewerProps) {
const { t } = useLocale();
const [content, setContent] = useState("");
const [editContent, setEditContent] = useState("");
const [mtime, setMtime] = useState("");
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(true);
const [toast, setToast] = useState("");
const [conflict, setConflict] = useState<ConflictResult | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
const { current: mdTheme } = useMarkdownTheme();
useEffect(() => {
setLoading(true);
setEditing(false);
fetchFile(filePath)
.then((data) => {
setContent(data.content);
setEditContent(data.content);
setMtime(data.mtime);
})
.catch(() => showToast(t("file.failedToLoad")))
.finally(() => setLoading(false));
}, [filePath, refreshKey]);
// Auto-refresh every 10s when not editing (for when WebSocket is unavailable)
useEffect(() => {
if (editing) return;
const interval = setInterval(() => {
fetchFile(filePath).then((data) => {
if (data.mtime !== mtime) {
setContent(data.content);
setEditContent(data.content);
setMtime(data.mtime);
}
}).catch(() => {});
}, 10000);
return () => clearInterval(interval);
}, [filePath, editing, mtime]);
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchFile(filePath)
.then((data) => {
setContent(data.content);
setEditContent(data.content);
setMtime(data.mtime);
showToast(t("file.reloaded") || "Refreshed");
})
.catch(() => showToast(t("file.failedToLoad")))
.finally(() => setRefreshing(false));
}, [filePath]);
const showToast = (msg: string) => {
setToast(msg);
setTimeout(() => setToast(""), 2000);
};
const hasChanges = editing && editContent !== content;
const handleSave = useCallback(async (force = false) => {
try {
const result = await saveFile(filePath, editContent, force ? undefined : mtime);
if ("error" in result && result.error === "conflict") {
setConflict(result as ConflictResult);
return;
}
if ("ok" in result && result.ok) {
setContent(editContent);
setMtime(result.mtime);
setEditing(false);
setConflict(null);
showToast(t("file.saved"));
}
} catch {
showToast(t("file.saveFailed"));
}
}, [filePath, editContent, mtime]);
const handleConflictOverwrite = useCallback(() => {
handleSave(true);
}, [handleSave]);
const handleConflictReload = useCallback(() => {
if (conflict) {
setContent(conflict.serverContent);
setEditContent(conflict.serverContent);
setMtime(conflict.serverMtime);
setConflict(null);
showToast(t("file.reloaded"));
}
}, [conflict]);
const handleCancel = () => {
if (hasChanges && !confirm(t("file.discardChanges"))) return;
setEditContent(content);
setEditing(false);
};
const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains("dark"));
useEffect(() => {
const obs = new MutationObserver(() => setIsDark(document.documentElement.classList.contains("dark")));
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => obs.disconnect();
}, []);
const handleEdit = () => {
setEditing(true);
};
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (hasChanges) e.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [hasChanges]);
const frontMatter = useMemo(() => parseFrontMatter(content), [content]);
// Apply markdown theme
useEffect(() => {
if (!contentRef.current || editing) return;
const article = contentRef.current.querySelector(".markdown-body");
if (!article || (!mdTheme.styles && !mdTheme.darkStyles)) {
cleanInlineStyles(article);
return;
}
applyThemeStyles(article as HTMLElement, mdTheme);
}, [content, mdTheme, editing, refreshKey, isDark]);
const fileStats = useMemo(() => {
const bytes = new Blob([content]).size;
const words = content.trim() ? content.trim().split(/\s+/).length : 0;
const sizeStr = bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
return { sizeStr, words };
}, [content]);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setShowScrollTop(e.currentTarget.scrollTop > 300);
}, []);
const scrollToTop = () => {
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
};
if (loading) {
return (
<div className="flex items-center justify-center h-full" style={{ color: "var(--text-faint)" }}>
<div className="w-5 h-5 border-2 border-t-blue-400 rounded-full animate-spin mr-3" style={{ borderColor: "var(--border)" }} />
{t("file.loading")}
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<header className="flex items-center justify-between px-4 py-2.5 border-b backdrop-blur shrink-0" style={{ borderColor: "var(--border)", background: "var(--bg-secondary)" }}>
<div className="min-w-0 flex items-center gap-3">
<Breadcrumb path={filePath} hasChanges={hasChanges} onNavigate={onNavigate} />
<span className="text-xs hidden sm:inline" style={{ color: "var(--text-faint)" }}>
{new Date(mtime).toLocaleString()}
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs hidden sm:flex gap-2" style={{ color: "var(--text-faint)" }}>
<span>{fileStats.sizeStr}</span>
<span>·</span>
<span>{fileStats.words} {t("file.words")}</span>
</span>
<div className="flex gap-2">
{editing ? (
<>
<button onClick={handleSave} className="btn-primary text-sm flex items-center gap-1">
<FloppyDisk className="w-3.5 h-3.5" /> {t("file.save")}
</button>
<button onClick={handleCancel} className="btn-secondary text-sm flex items-center gap-1">
<X className="w-3.5 h-3.5" /> {t("file.cancel")}
</button>
</>
) : (
<>
<button onClick={handleRefresh} className="btn-secondary text-sm flex items-center gap-1" disabled={refreshing} title="Refresh file">
<ArrowsClockwise className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
</button>
<PluginSlot name="fileviewer-toolbar" filePath={filePath} content={content} renderedRef={contentRef} />
<button onClick={handleEdit} className="btn-secondary text-sm flex items-center gap-1">
<PencilSimple className="w-3.5 h-3.5" /> {t("file.edit")}
</button>
</>
)}
</div>
</div>
</header>
{/* Content */}
<div className="flex-1 overflow-auto p-4 sm:p-6 relative" ref={contentRef} onScroll={handleScroll}>
{editing ? (
<Suspense fallback={<div style={{ padding: 20, color: "var(--text-muted)" }}>Loading editor...</div>}>
<MarkdownEditor
value={editContent}
onChange={setEditContent}
onSave={handleSave}
dark={isDark}
/>
</Suspense>
) : (
<article className="markdown-body max-w-3xl mx-auto">
{(frontMatter.meta || frontMatter.metadata) && (() => {
const md = frontMatter.metadata;
const cb = md?.clawdbot || md?.openclaw;
const emoji = cb?.emoji || "🧩";
const requires = cb?.requires;
const extraMeta = frontMatter.meta ? Object.entries(frontMatter.meta).filter(([k]) => k !== "name" && k !== "description") : [];
return (
<div className="mb-6 rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
{/* Header */}
<div className="px-5 py-2.5" style={{ background: isDark ? "linear-gradient(135deg, #1e293b, #1a1c2b)" : "linear-gradient(135deg, #f5f0eb, #faf8f5)" }}>
<div className="flex items-center gap-3">
{frontMatter.meta?.name && (
<div className="flex items-center gap-2">
<span className="text-lg">{emoji}</span>
<h2 className="text-lg font-bold" style={{ color: "var(--link)" }}>{frontMatter.meta.name}</h2>
</div>
)}
</div>
{frontMatter.meta?.description && (
<p className="text-sm mt-2 leading-relaxed" style={{ color: "var(--text-secondary)" }}>{frontMatter.meta.description}</p>
)}
</div>
{/* Tags: extra fields + requires */}
{(extraMeta.length > 0 || requires) && (
<div className="px-5 py-2.5 flex flex-wrap gap-2" style={{ background: "var(--bg-tertiary)", borderTop: "1px solid var(--border)" }}>
{extraMeta.map(([k, v]) => (
<span key={k} className="inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-1 font-medium" style={{ background: "var(--bg-active)", color: "var(--link)" }}>
<span style={{ color: "var(--text-faint)" }}>{k}:</span> {v}
</span>
))}
{requires?.skills?.map((s: string) => (
<span key={s} className="inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-1 font-medium" style={{ background: isDark ? "rgba(139,92,246,0.15)" : "rgba(139,92,246,0.1)", color: isDark ? "#a78bfa" : "#7c3aed" }}>
🔗 {s}
</span>
))}
{requires?.tools?.map((t: string) => (
<span key={t} className="inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-1 font-medium" style={{ background: isDark ? "rgba(34,197,94,0.15)" : "rgba(34,197,94,0.1)", color: isDark ? "#4ade80" : "#16a34a" }}>
🔧 {t}
</span>
))}
{requires?.secrets?.map((s: string) => (
<span key={s} className="inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-1 font-medium" style={{ background: isDark ? "rgba(251,191,36,0.15)" : "rgba(251,191,36,0.1)", color: isDark ? "#fbbf24" : "#d97706" }}>
🔑 {s}
</span>
))}
</div>
)}
</div>
);
})()}
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkFrontmatter]}
rehypePlugins={[rehypeRaw]}
components={{
pre({ children }) {
// Shiki/CodeBlock handles its own wrapper, so just pass through
return <>{children}</>;
},
code: CodeBlock,
// Mask in plain text nodes within paragraphs
p({ children }) {
return <p>{maskChildren(children, onOpenFile)}</p>;
},
li({ children }) {
return <li>{maskChildren(children, onOpenFile)}</li>;
},
td({ children }) {
return <td>{maskChildren(children, onOpenFile)}</td>;
},
}}
>{content}</ReactMarkdown>
</article>
)}
</div>
{/* Scroll to top */}
{showScrollTop && !editing && (
<button onClick={scrollToTop} className="scroll-top-btn" title={t("file.backToTop")}>
<ArrowUp className="w-5 h-5" />
</button>
)}
{/* Conflict Dialog */}
{conflict && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl" style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}>
<div className="flex items-center gap-3 mb-4">
<Warning className="w-6 h-6 text-yellow-400 shrink-0" />
<h3 className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{t("file.conflictTitle")}</h3>
</div>
<p className="text-sm mb-1" style={{ color: "var(--text-secondary)" }}>
{t("file.conflictDesc")}
</p>
<p className="text-xs mb-5" style={{ color: "var(--text-faint)" }}>
{t("file.conflictTime")}: {new Date(conflict.serverMtime).toLocaleString()}
</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setConflict(null)} className="btn-secondary text-sm">
{t("file.cancel")}
</button>
<button onClick={handleConflictReload} className="btn-secondary text-sm">
{t("file.conflictReload")}
</button>
<button onClick={handleConflictOverwrite} className="btn-primary text-sm">
{t("file.conflictOverwrite")}
</button>
</div>
</div>
</div>
)}
{/* Toast */}
{toast && <div className="toast">{toast}</div>}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useRef, useCallback } from "react";
import { EditorView, keymap, lineNumbers } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { oneDark } from "@codemirror/theme-one-dark";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language";
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
onSave: () => void;
dark?: boolean;
}
export function MarkdownEditor({ value, onChange, onSave, dark = true }: MarkdownEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
onChangeRef.current = onChange;
onSaveRef.current = onSave;
// Track if update is from external prop change
const externalUpdate = useRef(false);
const createView = useCallback(() => {
if (!containerRef.current) return;
viewRef.current?.destroy();
const themeExtensions = dark
? [oneDark]
: [syntaxHighlighting(defaultHighlightStyle)];
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
EditorView.lineWrapping,
keymap.of([
...defaultKeymap,
indentWithTab,
{ key: "Mod-s", run: () => { onSaveRef.current(); return true; } },
]),
markdown({ base: markdownLanguage, codeLanguages: languages }),
...themeExtensions,
EditorView.updateListener.of((update) => {
if (update.docChanged && !externalUpdate.current) {
onChangeRef.current(update.state.doc.toString());
}
}),
EditorView.theme({
"&": { height: "100%", fontSize: "14px" },
".cm-scroller": { overflow: "auto" },
".cm-content": { fontFamily: "'JetBrains Mono', 'Fira Code', monospace" },
".cm-gutters": { borderRight: "none" },
}),
],
});
viewRef.current = new EditorView({ state, parent: containerRef.current });
}, [dark]);
// Create/recreate on dark change
useEffect(() => {
createView();
return () => viewRef.current?.destroy();
}, [createView]);
// Sync external value changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (value !== current) {
externalUpdate.current = true;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
});
externalUpdate.current = false;
}
}, [value]);
// Focus on mount
useEffect(() => {
setTimeout(() => viewRef.current?.focus(), 50);
}, []);
return (
<div
ref={containerRef}
className="markdown-editor-container"
style={{ height: "100%", minHeight: "100%" }}
/>
);
}

View File

@@ -0,0 +1,245 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { searchFiles, semanticSearch, fetchCapabilities, type SearchResult, type SemanticResult } from "../api";
import { MagnifyingGlass, FileText, TextAa, Brain } from "@phosphor-icons/react";
import { useLocale } from "../hooks/useLocale";
type SearchMode = "text" | "bm25" | "vector";
interface SearchPanelProps {
onSelect: (path: string) => void;
onClose: () => void;
}
export function SearchPanel({ onSelect, onClose }: SearchPanelProps) {
const { t } = useLocale();
const [query, setQuery] = useState("");
const [mode, setMode] = useState<SearchMode>("text");
const [textResults, setTextResults] = useState<SearchResult[]>([]);
const [semanticResults, setSemanticResults] = useState<SemanticResult[]>([]);
const [loading, setLoading] = useState(false);
const [hasBm25, setHasBm25] = useState(false);
const [hasVector, setHasVector] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
inputRef.current?.focus();
fetchCapabilities().then((cap) => {
setHasBm25(cap.qmdBm25);
setHasVector(cap.qmdVector);
});
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const doSearch = useCallback((q: string, m: SearchMode) => {
if (q.length < 2) {
setTextResults([]);
setSemanticResults([]);
return;
}
setLoading(true);
if (m === "text") {
searchFiles(q)
.then(setTextResults)
.catch(() => setTextResults([]))
.finally(() => setLoading(false));
} else {
semanticSearch(q, m)
.then(setSemanticResults)
.catch(() => setSemanticResults([]))
.finally(() => setLoading(false));
}
}, []);
const handleInput = (val: string) => {
setQuery(val);
clearTimeout(timerRef.current);
const delay = mode === "text" ? 300 : 500;
timerRef.current = setTimeout(() => doSearch(val, mode), delay);
};
const handleModeChange = (m: SearchMode) => {
setMode(m);
setTextResults([]);
setSemanticResults([]);
if (query.length >= 2) {
setLoading(true);
const delay = m === "text" ? 0 : 100;
setTimeout(() => doSearch(query, m), delay);
}
};
const handleSelect = (path: string) => {
onSelect(path);
onClose();
};
const totalMatches = mode === "text"
? textResults.reduce((s, r) => s + r.matches.length, 0)
: semanticResults.length;
const modeButtons: { key: SearchMode; icon: typeof TextAa; label: string }[] = [
{ key: "text", icon: TextAa, label: t("search.modeText") },
...(hasBm25 ? [{ key: "bm25" as SearchMode, icon: MagnifyingGlass, label: "BM25" }] : []),
...(hasVector ? [{ key: "vector" as SearchMode, icon: Brain, label: t("search.modeSemantic") }] : []),
];
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div
className="w-full max-w-2xl rounded-xl shadow-2xl overflow-hidden"
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
onClick={(e) => e.stopPropagation()}
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b" style={{ borderColor: "var(--border)" }}>
<MagnifyingGlass className="w-5 h-5 shrink-0" style={{ color: "var(--text-faint)" }} />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleInput(e.target.value)}
placeholder={t("search.placeholder")}
className="flex-1 bg-transparent outline-none text-base"
style={{ color: "var(--text-primary)" }}
/>
{loading && (
<div className="flex items-center gap-2">
{mode !== "text" && <span className="text-xs" style={{ color: "var(--text-faint)" }}></span>}
<div className="w-4 h-4 border-2 border-t-blue-400 rounded-full animate-spin" style={{ borderColor: "var(--border)" }} />
</div>
)}
<kbd className="hidden sm:inline-block text-xs px-1.5 py-0.5 rounded" style={{ color: "var(--text-faint)", background: "var(--bg-tertiary)", border: "1px solid var(--border)" }}>
ESC
</kbd>
</div>
{/* Mode toggle — only show if QMD is available */}
{(hasBm25 || hasVector) && <div className="flex items-center gap-1 px-4 py-2 border-b" style={{ borderColor: "var(--border)" }}>
{modeButtons.map(({ key, icon: Icon, label }) => (
<button
key={key}
onClick={() => handleModeChange(key)}
className="flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium transition-colors"
style={{
background: mode === key ? "var(--accent-bg, rgba(59,130,246,0.15))" : "transparent",
color: mode === key ? "var(--accent, #3b82f6)" : "var(--text-faint)",
border: mode === key ? "1px solid var(--accent, #3b82f6)" : "1px solid transparent",
}}
>
<Icon size={14} weight={mode === key ? "bold" : "regular"} />
{label}
</button>
))}
</div>}
{/* Results */}
<div className="max-h-[50vh] overflow-y-auto">
{loading && query.length >= 2 && mode !== "text" && (
<div className="px-4 py-3 space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse">
<div className="h-4 rounded w-2/3 mb-2" style={{ background: "var(--bg-tertiary)" }} />
<div className="h-3 rounded w-full mb-1" style={{ background: "var(--bg-tertiary)", opacity: 0.6 }} />
<div className="h-3 rounded w-4/5" style={{ background: "var(--bg-tertiary)", opacity: 0.4 }} />
</div>
))}
</div>
)}
{query.length >= 2 && totalMatches === 0 && !loading && (
<div className="px-4 py-8 text-center" style={{ color: "var(--text-faint)" }}>
{t("search.noResults")} &ldquo;{query}&rdquo;
</div>
)}
{/* Text search results */}
{mode === "text" && textResults.map((r) => (
<button
key={r.path}
onClick={() => handleSelect(r.path)}
className="w-full text-left px-4 py-3 transition-colors border-b last:border-0"
style={{ borderColor: "var(--border-light)" }}
onMouseEnter={(e) => e.currentTarget.style.background = "var(--bg-hover)"}
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
>
<div className="text-sm font-medium text-blue-400 mb-1">
<FileText className="w-3.5 h-3.5 inline-block mr-1" />{r.path}
</div>
{r.matches.map((m, i) => (
<div key={i} className="text-xs truncate pl-4" style={{ color: "var(--text-muted)" }}>
<span className="mr-2" style={{ color: "var(--text-faint)" }}>L{m.line}</span>
{highlightMatch(m.text, query)}
</div>
))}
</button>
))}
{/* Semantic search results */}
{mode !== "text" && semanticResults.map((r, idx) => (
<button
key={`${r.path}-${idx}`}
onClick={() => handleSelect(r.path)}
className="w-full text-left px-4 py-3 transition-colors border-b last:border-0"
style={{ borderColor: "var(--border-light)" }}
onMouseEnter={(e) => e.currentTarget.style.background = "var(--bg-hover)"}
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
>
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-medium text-blue-400">
<FileText className="w-3.5 h-3.5 inline-block mr-1" />{r.path}
</div>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
background: r.score >= 50 ? "rgba(34,197,94,0.15)" : "rgba(234,179,8,0.15)",
color: r.score >= 50 ? "#22c55e" : "#eab308",
}}
>
{r.score}% {t("search.relevance")}
</span>
</div>
{r.title && (
<div className="text-xs font-medium mb-0.5" style={{ color: "var(--text-secondary)" }}>
{r.title}
</div>
)}
<div className="text-xs line-clamp-2 pl-4" style={{ color: "var(--text-muted)" }}>
{r.snippet}
</div>
</button>
))}
</div>
{/* Footer */}
{totalMatches > 0 && (
<div className="px-4 py-2 border-t text-xs" style={{ borderColor: "var(--border)", color: "var(--text-faint)" }}>
{mode === "text"
? `${textResults.length} ${t("search.files")} · ${totalMatches} ${t("search.matches")}`
: `${semanticResults.length} ${t("search.results")} · ${mode === "vector" ? "🧠" : "📊"} ${mode.toUpperCase()}`
}
</div>
)}
</div>
</div>
);
}
function highlightMatch(text: string, query: string) {
const lower = text.toLowerCase();
const idx = lower.indexOf(query.toLowerCase());
if (idx === -1) return <span>{text}</span>;
return (
<span>
{text.slice(0, idx)}
<mark className="bg-yellow-500/30 text-yellow-600 dark:text-yellow-200 rounded px-0.5">{text.slice(idx, idx + query.length)}</mark>
{text.slice(idx + query.length)}
</span>
);
}

View File

@@ -0,0 +1,77 @@
import { useSensitive } from "../hooks/useSensitive";
/**
* Patterns that match API tokens, keys, secrets, passwords, etc.
* Each match will be blurred when sensitive mode is on.
*/
const SENSITIVE_PATTERNS = [
// Generic API keys/tokens (long hex/alphanum strings after key-like labels)
/(?<=(?:key|token|secret|password|apikey|api_key|api-key|bearer|authorization)\s*[:=]\s*)[A-Za-z0-9_\-./+]{16,}/gi,
// Specific formats
/pplx-[A-Za-z0-9]{40,}/g, // Perplexity
/sk-[A-Za-z0-9_\-]{32,}/g, // OpenAI-style
/re_[A-Za-z0-9_]{20,}/g, // Resend
/ghp_[A-Za-z0-9]{36,}/g, // GitHub PAT
/gho_[A-Za-z0-9]{36,}/g, // GitHub OAuth
/xai-[A-Za-z0-9]{40,}/g, // xAI
/GOCSPX-[A-Za-z0-9_\-]{20,}/g, // Google client secret
/AIza[A-Za-z0-9_\-]{30,}/g, // Google API key
/[0-9a-f]{48,}/g, // Long hex strings (48+ chars, likely tokens)
/eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]+/g, // JWT tokens
];
/**
* Replace sensitive substrings in text with masked spans.
*/
export function maskSensitiveText(text: string, hidden: boolean): (string | JSX.Element)[] {
if (!hidden) return [text];
// Collect all match ranges
const ranges: { start: number; end: number }[] = [];
for (const pattern of SENSITIVE_PATTERNS) {
const re = new RegExp(pattern.source, pattern.flags);
let m;
while ((m = re.exec(text)) !== null) {
ranges.push({ start: m.index, end: m.index + m[0].length });
}
}
if (ranges.length === 0) return [text];
// Merge overlapping ranges
ranges.sort((a, b) => a.start - b.start);
const merged: { start: number; end: number }[] = [ranges[0]];
for (let i = 1; i < ranges.length; i++) {
const last = merged[merged.length - 1];
if (ranges[i].start <= last.end) {
last.end = Math.max(last.end, ranges[i].end);
} else {
merged.push(ranges[i]);
}
}
// Build result
const result: (string | JSX.Element)[] = [];
let pos = 0;
for (const { start, end } of merged) {
if (start > pos) result.push(text.slice(pos, start));
result.push(
<span key={start} className="sensitive-blur" title="Sensitive content hidden">
{text.slice(start, end)}
</span>
);
pos = end;
}
if (pos < text.length) result.push(text.slice(pos));
return result;
}
/**
* Wrap around inline text nodes to auto-mask sensitive content.
*/
export function SensitiveText({ children }: { children: string }) {
const { hidden } = useSensitive();
if (typeof children !== "string") return <>{children}</>;
const parts = maskSensitiveText(children, hidden);
return <>{parts}</>;
}

View File

@@ -0,0 +1,305 @@
import { useState, useEffect } from "react";
import { fetchSettings, saveSettings, testEmbeddingConnection, type EmbeddingSettings, getBaseUrl } from "../api";
import { Gear, FloppyDisk, Lightning, CheckCircle, XCircle, Database, Files } from "@phosphor-icons/react";
import { useLocale } from "../hooks/useLocale";
export function SettingsPage() {
const { t } = useLocale();
const [settings, setSettings] = useState<EmbeddingSettings>({
enabled: false,
apiUrl: "",
apiKey: "",
model: "",
apiKeySet: false
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message?: string } | null>(null);
const [touched, setTouched] = useState(false);
const [embStats, setEmbStats] = useState<{ cachedFiles: number; totalFiles: number; coverage: number; dbSize: number; model: string } | null>(null);
useEffect(() => {
loadSettings();
loadEmbStats();
}, []);
const loadEmbStats = async () => {
try {
const r = await fetch(`${getBaseUrl()}/api/settings/embedding-stats`);
setEmbStats(await r.json());
} catch {}
};
const loadSettings = async () => {
try {
const data = await fetchSettings();
setSettings(data.embedding);
} catch (err) {
console.error("Failed to load settings:", err);
} finally {
setLoading(false);
}
};
const handleChange = (field: keyof EmbeddingSettings, value: any) => {
setSettings(prev => ({ ...prev, [field]: value }));
setTouched(true);
};
const handleSave = async () => {
setSaving(true);
try {
await saveSettings({ embedding: settings });
setTouched(false);
// Reload to get the safe version (with apiKey hidden)
await loadSettings();
} catch (err) {
console.error("Failed to save settings:", err);
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const result = await testEmbeddingConnection(settings);
setTestResult({
success: result.success,
message: result.error || "Connection successful"
});
} catch (err: any) {
setTestResult({
success: false,
message: err.message || "Test failed"
});
} finally {
setTesting(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-full" style={{ color: "var(--text-faint)" }}>
<div className="w-5 h-5 border-2 border-t-blue-400 rounded-full animate-spin mr-3" style={{ borderColor: "var(--border)" }} />
Loading settings...
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6">
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<Gear className="w-7 h-7 text-blue-400" /> {t("settings.title") || "⚙️ Settings"}
</h1>
{/* Embedding Settings */}
<section className="rounded-xl p-5" style={{ background: "var(--bg-tertiary)", border: "1px solid var(--border)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-primary)" }}>
{t("settings.embedding.title") || "向量搜索 (Embedding)"}
</h2>
<div className="space-y-4">
{/* Enable/Disable toggle */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium" style={{ color: "var(--text-secondary)" }}>
{t("settings.embedding.enable") || "启用向量搜索"}
</div>
<div className="text-sm" style={{ color: "var(--text-faint)" }}>
{t("settings.embedding.enableDesc") || "使用 OpenAI Embeddings API 进行语义搜索"}
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.enabled}
onChange={(e) => handleChange("enabled", e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 rounded-full peer bg-gray-700 peer-checked:bg-blue-600 peer-focus:ring-2 peer-focus:ring-blue-800" style={{ background: settings.enabled ? "var(--link)" : "var(--border)" }}>
<div className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full transition-transform ${settings.enabled ? "translate-x-5" : ""}`} style={{ background: "var(--bg-primary)" }} />
</div>
</label>
</div>
{settings.enabled && (
<div className="space-y-4 pt-2">
{/* API URL */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
{t("settings.embedding.apiUrl") || "API 地址"}
</label>
<input
type="text"
value={settings.apiUrl}
onChange={(e) => handleChange("apiUrl", e.target.value)}
placeholder="https://api.openai.com/v1/embeddings"
className="w-full px-3 py-2 rounded-md text-sm"
style={{
background: "var(--bg-secondary)",
color: "var(--text-primary)",
border: "1px solid var(--border)",
}}
/>
<div className="text-xs mt-1" style={{ color: "var(--text-faint)" }}>
{t("settings.embedding.apiUrlDesc") || "OpenAI Embeddings API 或兼容的端点"}
</div>
</div>
{/* API Key */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
{t("settings.embedding.apiKey") || "API 密钥"}
</label>
<input
type="password"
value={settings.apiKeySet ? "••••••" : settings.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
placeholder="sk-..."
className="w-full px-3 py-2 rounded-md text-sm"
style={{
background: "var(--bg-secondary)",
color: "var(--text-primary)",
border: "1px solid var(--border)",
}}
/>
<div className="text-xs mt-1" style={{ color: "var(--text-faint)" }}>
{settings.apiKeySet
? (t("settings.embedding.apiKeySet") || "API 密钥已设置")
: (t("settings.embedding.apiKeyDesc") || "OpenAI API 密钥 (sk-...)")}
</div>
</div>
{/* Model */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
{t("settings.embedding.model") || "模型"}
</label>
<input
type="text"
value={settings.model}
onChange={(e) => handleChange("model", e.target.value)}
placeholder="text-embedding-3-small"
className="w-full px-3 py-2 rounded-md text-sm"
style={{
background: "var(--bg-secondary)",
color: "var(--text-primary)",
border: "1px solid var(--border)",
}}
/>
<div className="text-xs mt-1" style={{ color: "var(--text-faint)" }}>
{t("settings.embedding.modelDesc") || "Embedding 模型名称,如 text-embedding-3-small"}
</div>
</div>
{/* Test Connection */}
<div className="pt-2">
<button
onClick={handleTest}
disabled={testing || !settings.apiUrl || !settings.apiKey || !settings.model}
className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style={{
background: "var(--bg-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
}}
>
{testing ? (
<>
<div className="w-4 h-4 border-2 border-t-blue-400 rounded-full animate-spin" style={{ borderColor: "var(--border)" }} />
{t("settings.embedding.testing") || "测试中..."}
</>
) : (
<>
<Lightning className="w-4 h-4" />
{t("settings.embedding.test") || "测试连接"}
</>
)}
</button>
{testResult && (
<div className={`flex items-center gap-2 mt-2 text-sm ${testResult.success ? "text-green-400" : "text-red-400"}`}>
{testResult.success ? (
<CheckCircle className="w-4 h-4" />
) : (
<XCircle className="w-4 h-4" />
)}
{testResult.message}
</div>
)}
</div>
</div>
)}
</div>
{/* Embedding Stats */}
{settings.enabled && embStats && embStats.cachedFiles > 0 && (
<div className="mt-4 p-3 rounded-lg" style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}>
<div className="flex items-center gap-2 mb-2 text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
<Database size={16} className="text-blue-400" />
Embedding
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<div className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>
{embStats.cachedFiles}/{embStats.totalFiles}
</div>
<div className="text-xs" style={{ color: "var(--text-faint)" }}></div>
</div>
<div>
<div className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>
{embStats.coverage}%
</div>
<div className="text-xs" style={{ color: "var(--text-faint)" }}></div>
</div>
<div>
<div className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>
{embStats.dbSize > 1048576 ? `${(embStats.dbSize / 1048576).toFixed(1)}MB` : `${Math.round(embStats.dbSize / 1024)}KB`}
</div>
<div className="text-xs" style={{ color: "var(--text-faint)" }}></div>
</div>
</div>
{/* Coverage bar */}
<div className="mt-2 w-full h-1.5 rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
<div
className="h-full rounded-full bg-blue-500 transition-all"
style={{ width: `${embStats.coverage}%` }}
/>
</div>
<div className="text-[11px] mt-1" style={{ color: "var(--text-faint)" }}>
: {embStats.model}
</div>
</div>
)}
{/* Save Button */}
<div className="flex justify-end mt-6 pt-4 border-t" style={{ borderColor: "var(--border)" }}>
<button
onClick={handleSave}
disabled={saving || !touched}
className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style={{
background: "var(--link)",
color: "white",
}}
>
{saving ? (
<>
<div className="w-4 h-4 border-2 border-t-white rounded-full animate-spin" style={{ borderColor: "rgba(255,255,255,0.3)" }} />
{t("settings.saving") || "保存中..."}
</>
) : (
<>
<FloppyDisk className="w-4 h-4" />
{t("settings.save") || "保存设置"}
</>
)}
</button>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { PuzzlePiece, ArrowSquareOut, FileText } from "@phosphor-icons/react";
import type { SkillInfo } from "../api";
import { useLocale } from "../hooks/useLocale";
interface SkillsPageProps {
skills: SkillInfo[];
onOpenFile: (path: string) => void;
}
export function SkillsPage({ skills, onOpenFile }: SkillsPageProps) {
const { t } = useLocale();
return (
<div className="h-full overflow-y-auto p-6 lg:p-10">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2 flex items-center gap-3">
<PuzzlePiece className="w-7 h-7 text-purple-400" />
{t("skills.title") || "Skills"}
</h1>
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
{t("skills.description") || "Agent capabilities and extensions"}
</p>
</div>
{/* Skills Grid */}
{skills.length === 0 ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
{t("skills.empty") || "No skills found"}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{skills.map((skill) => (
<button
key={skill.id}
onClick={() => onOpenFile(skill.path)}
className="p-4 rounded-xl border text-left transition-all hover:shadow-md hover:border-purple-500/30 group"
style={{ background: "var(--bg-secondary)", borderColor: "var(--border)" }}
>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-400 shrink-0">
<PuzzlePiece className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium mb-1 group-hover:text-purple-400 transition-colors truncate">
{skill.name}
</div>
{skill.description && (
<div className="text-xs line-clamp-2" style={{ color: "var(--text-muted)" }}>
{skill.description}
</div>
)}
<div className="flex items-center gap-1 mt-2 text-[10px]" style={{ color: "var(--text-faint)" }}>
<FileText className="w-3 h-3" />
<span className="truncate">{skill.path}</span>
</div>
</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,311 @@
import { useEffect, useMemo, useState } from "react";
import { Hash, FileText, CaretRight, Clock, Tag as TagIcon, X } from "@phosphor-icons/react";
import { fetchTags, fetchFilesByTag, type TagInfo, type FileWithTags } from "../api";
import { useLocale } from "../hooks/useLocale";
interface Props {
onOpenFile: (path: string) => void;
}
export function Tags({ onOpenFile }: Props) {
const { t } = useLocale();
const [tags, setTags] = useState<TagInfo[]>([]);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [files, setFiles] = useState<FileWithTags[]>([]);
const [loading, setLoading] = useState(true);
const [filesLoading, setFilesLoading] = useState(false);
// Load all tags on mount
useEffect(() => {
(async () => {
setLoading(true);
try {
const data = await fetchTags();
setTags(data);
} catch (e) {
console.error("Tags load failed:", e);
} finally {
setLoading(false);
}
})();
}, []);
// Load files when tag selected
useEffect(() => {
if (!selectedTag) {
setFiles([]);
return;
}
(async () => {
setFilesLoading(true);
try {
const data = await fetchFilesByTag(selectedTag);
setFiles(data);
} catch (e) {
console.error("Files by tag load failed:", e);
} finally {
setFilesLoading(false);
}
})();
}, [selectedTag]);
// Calculate tag sizes for cloud display
const tagSizes = useMemo(() => {
if (tags.length === 0) return [];
const maxCount = Math.max(...tags.map(t => t.count));
const minCount = Math.min(...tags.map(t => t.count));
const range = maxCount - minCount || 1;
return tags.map(tag => {
// Normalize to 1-5 scale
const size = 1 + ((tag.count - minCount) / range) * 4;
return { ...tag, size: Math.round(size) };
});
}, [tags]);
// Group tags by first letter/category
const groupedTags = useMemo(() => {
const groups: Record<string, typeof tags> = {};
for (const tag of tags) {
const firstChar = tag.name.charAt(0);
const key = /[\u4e00-\u9fa5]/.test(firstChar) ? "中文" :
/[a-zA-Z]/.test(firstChar) ? firstChar.toUpperCase() :
"#";
(groups[key] ||= []).push(tag);
}
return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b));
}, [tags]);
const stats = useMemo(() => ({
totalTags: tags.length,
totalFiles: tags.reduce((s, t) => s + t.count, 0),
avgPerTag: tags.length > 0 ? (tags.reduce((s, t) => s + t.count, 0) / tags.length).toFixed(1) : "0",
}), [tags]);
// Get color based on tag size
const getTagColor = (size: number) => {
const colors = [
"var(--text-faint)",
"var(--text-muted)",
"var(--text-secondary)",
"var(--accent)",
"#3b82f6",
];
return colors[size - 1] || colors[0];
};
const getTagSize = (size: number) => {
const sizes = ["0.75rem", "0.875rem", "1rem", "1.125rem", "1.25rem"];
return sizes[size - 1] || sizes[0];
};
return (
<div className="max-w-4xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg" style={{ background: "var(--bg-secondary)" }}>
<TagIcon size={24} style={{ color: "var(--accent)" }} />
</div>
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>
{t("tags.title", "标签")}
</h1>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
{t("tags.subtitle", "按主题浏览记忆")}
</p>
</div>
</div>
{selectedTag && (
<button
onClick={() => setSelectedTag(null)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{ background: "var(--bg-secondary)", color: "var(--text-muted)" }}
onMouseEnter={e => e.currentTarget.style.background = "var(--bg-hover)"}
onMouseLeave={e => e.currentTarget.style.background = "var(--bg-secondary)"}
>
<X size={14} />
{t("tags.clear", "清除筛选")}
</button>
)}
</div>
{/* Stats bar */}
<div className="flex items-center gap-6 mb-6 px-4 py-3 rounded-lg" style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}>
<div className="flex items-center gap-2">
<TagIcon size={18} className="text-blue-400" />
<span className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{stats.totalTags}</span>
<span className="text-xs" style={{ color: "var(--text-faint)" }}>{t("tags.totalTags", "标签")}</span>
</div>
<div className="flex items-center gap-2">
<FileText size={18} className="text-green-400" />
<span className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{stats.totalFiles}</span>
<span className="text-xs" style={{ color: "var(--text-faint)" }}>{t("tags.totalFiles", "文件")}</span>
</div>
<div className="flex items-center gap-2">
<Hash size={18} className="text-purple-400" />
<span className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{stats.avgPerTag}</span>
<span className="text-xs" style={{ color: "var(--text-faint)" }}>{t("tags.avgPerTag", "平均")}</span>
</div>
{loading && (
<div className="ml-auto w-4 h-4 border-2 border-t-blue-400 rounded-full animate-spin" style={{ borderColor: "var(--border)" }} />
)}
</div>
{/* Selected tag files view */}
{selectedTag ? (
<div className="space-y-3">
<div className="flex items-center gap-2 mb-4">
<span className="text-sm" style={{ color: "var(--text-muted)" }}>{t("tags.showing", "显示标签")}</span>
<span className="px-3 py-1 rounded-full text-sm font-medium text-white bg-blue-500">
{selectedTag}
</span>
<span className="text-sm" style={{ color: "var(--text-faint)" }}>
({files.length} {t("tags.files", "个文件")})
</span>
</div>
{filesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-6 h-6 border-2 border-t-blue-400 rounded-full animate-spin" style={{ borderColor: "var(--border)" }} />
</div>
) : files.length === 0 ? (
<div className="text-center py-12" style={{ color: "var(--text-faint)" }}>
<FileText size={40} className="mx-auto mb-3 opacity-30" />
<p>{t("tags.noFiles", "没有找到相关文件")}</p>
</div>
) : (
<div className="space-y-2">
{files.map((file, i) => (
<button
key={file.path}
onClick={() => onOpenFile(file.path)}
className="w-full text-left p-4 rounded-lg transition-colors"
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
onMouseEnter={e => e.currentTarget.style.background = "var(--bg-hover)"}
onMouseLeave={e => e.currentTarget.style.background = "var(--bg-secondary)"}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<FileText size={16} style={{ color: "var(--accent)" }} />
<span className="font-medium truncate" style={{ color: "var(--text-primary)" }}>
{file.title}
</span>
</div>
<p className="text-sm truncate" style={{ color: "var(--text-muted)" }}>
{file.preview}
</p>
<div className="flex flex-wrap gap-1.5 mt-2">
{file.tags.map(tag => (
<span
key={tag}
onClick={(e) => {
e.stopPropagation();
setSelectedTag(tag);
}}
className="text-[11px] px-2 py-0.5 rounded-full cursor-pointer transition-colors"
style={{
background: tag === selectedTag ? "rgba(59,130,246,0.2)" : "var(--bg-tertiary)",
color: tag === selectedTag ? "#3b82f6" : "var(--text-faint)",
}}
>
{tag}
</span>
))}
</div>
</div>
{file.date && (
<div className="shrink-0 text-xs" style={{ color: "var(--text-faint)" }}>
{file.date}
</div>
)}
</div>
</button>
))}
</div>
)}
</div>
) : (
/* Tag cloud view */
<div className="space-y-6">
{/* Popular tags cloud */}
{tagSizes.length > 0 && (
<div className="p-5 rounded-lg" style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}>
<h2 className="text-sm font-semibold mb-4 flex items-center gap-2" style={{ color: "var(--text-secondary)" }}>
<Hash size={16} />
{t("tags.popular", "热门标签")}
</h2>
<div className="flex flex-wrap gap-2">
{tagSizes.slice(0, 30).map(({ name, count, size }) => (
<button
key={name}
onClick={() => setSelectedTag(name)}
className="px-3 py-1.5 rounded-full transition-all hover:scale-105"
style={{
fontSize: getTagSize(size),
color: getTagColor(size),
background: size >= 4 ? "rgba(59,130,246,0.1)" : "var(--bg-tertiary)",
border: `1px solid ${size >= 4 ? "rgba(59,130,246,0.3)" : "var(--border)"}`,
}}
title={`${count} ${t("tags.files", "个文件")}`}
>
{name}
<span className="ml-1 opacity-50 text-xs">{count}</span>
</button>
))}
</div>
</div>
)}
{/* Grouped tags list */}
<div className="space-y-4">
{groupedTags.map(([key, groupTags]) => (
<div key={key} className="p-4 rounded-lg" style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}>
<h3 className="text-xs font-bold uppercase tracking-wider mb-3 flex items-center gap-2" style={{ color: "var(--text-faint)" }}>
<span className="w-5 h-5 flex items-center justify-center rounded" style={{ background: "var(--bg-tertiary)" }}>
{key}
</span>
<span>{groupTags.length} {t("tags.tags", "个标签")}</span>
</h3>
<div className="flex flex-wrap gap-2">
{groupTags.map(({ name, count }) => (
<button
key={name}
onClick={() => setSelectedTag(name)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-sm transition-colors"
style={{ background: "var(--bg-tertiary)", color: "var(--text-muted)" }}
onMouseEnter={e => {
e.currentTarget.style.background = "var(--accent)";
e.currentTarget.style.color = "white";
}}
onMouseLeave={e => {
e.currentTarget.style.background = "var(--bg-tertiary)";
e.currentTarget.style.color = "var(--text-muted)";
}}
>
<Hash size={12} />
{name}
<span className="text-[10px] opacity-60 px-1 rounded" style={{ background: "rgba(0,0,0,0.2)" }}>
{count}
</span>
</button>
))}
</div>
</div>
))}
</div>
{tags.length === 0 && !loading && (
<div className="text-center py-16" style={{ color: "var(--text-faint)" }}>
<TagIcon size={48} className="mx-auto mb-3 opacity-30" />
<p>{t("tags.empty", "暂无标签")}</p>
<p className="text-sm mt-1">{t("tags.emptyHint", "在 Markdown 中使用 ## 标题 或 #标签 来创建标签")}</p>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,242 @@
import { useEffect, useMemo, useState } from "react";
import { Calendar, FileText, CaretDown, CaretRight, Clock, Hash, Notebook, TextAa } from "@phosphor-icons/react";
import { fetchTimeline, type TimelineEntry } from "../api";
import { useLocale } from "../hooks/useLocale";
type DiaryEntry = TimelineEntry;
interface MonthGroup {
key: string;
label: string;
entries: DiaryEntry[];
totalChars: number;
}
interface Props {
onOpenFile: (path: string) => void;
}
export function Timeline({ onOpenFile }: Props) {
const { t, locale } = useLocale();
const [entries, setEntries] = useState<DiaryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [tagFilter, setTagFilter] = useState<string | null>(null);
useEffect(() => {
(async () => {
setLoading(true);
try {
const data = await fetchTimeline();
setEntries(data);
const now = new Date().toISOString().slice(0, 7);
setExpanded(new Set([now]));
} catch (e) {
console.error("Timeline load failed:", e);
} finally {
setLoading(false);
}
})();
}, []);
const filtered = useMemo(() => {
if (!tagFilter) return entries;
return entries.filter(e => e.tags.some(t => t.includes(tagFilter)));
}, [entries, tagFilter]);
const months = useMemo((): MonthGroup[] => {
const map: Record<string, DiaryEntry[]> = {};
for (const e of filtered) {
const k = e.date.slice(0, 7);
(map[k] ||= []).push(e);
}
const zhMonths = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
const enMonths = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return Object.entries(map)
.sort(([a], [b]) => b.localeCompare(a))
.map(([k, entries]) => {
const [y, m] = k.split("-");
const mi = parseInt(m) - 1;
const label = locale.startsWith("zh") ? `${y}${zhMonths[mi]}` : `${enMonths[mi]} ${y}`;
return {
key: k,
label,
entries: entries.sort((a, b) => b.date.localeCompare(a.date)),
totalChars: entries.reduce((s, e) => s + e.charCount, 0),
};
});
}, [filtered, locale]);
const allTags = useMemo(() => {
const count: Record<string, number> = {};
for (const e of entries) for (const t of e.tags) count[t] = (count[t] || 0) + 1;
return Object.entries(count).sort(([, a], [, b]) => b - a).slice(0, 12);
}, [entries]);
const stats = useMemo(() => ({
total: entries.length,
chars: entries.reduce((s, e) => s + e.charCount, 0),
months: new Set(entries.map(e => e.date.slice(0, 7))).size,
}), [entries]);
const toggle = (k: string) => setExpanded(prev => {
const next = new Set(prev);
next.has(k) ? next.delete(k) : next.add(k);
return next;
});
const weekday = (d: string) => {
const days = locale.startsWith("zh")
? ["日", "一", "二", "三", "四", "五", "六"]
: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return days[new Date(d + "T00:00:00").getDay()];
};
const fmtChars = (n: number) => n >= 10000 ? `${(n / 10000).toFixed(1)}` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
return (
<div className="max-w-3xl mx-auto px-4 py-6">
{/* Stats bar */}
<div className="flex items-center gap-6 mb-6 px-4 py-3 rounded-lg" style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}>
<div className="flex items-center gap-2">
<Notebook size={18} className="text-blue-400" />
<span className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{stats.total}</span>
<span className="text-xs" style={{ color: "var(--text-faint)" }}>{t("timeline.entries")}</span>
</div>
<div className="flex items-center gap-2">
<TextAa size={18} className="text-green-400" />
<span className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{fmtChars(stats.chars)}</span>
<span className="text-xs" style={{ color: "var(--text-faint)" }}>{t("timeline.chars")}</span>
</div>
<div className="flex items-center gap-2">
<Calendar size={18} className="text-purple-400" />
<span className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{stats.months}</span>
<span className="text-xs" style={{ color: "var(--text-faint)" }}>{t("timeline.months")}</span>
</div>
{loading && (
<div className="ml-auto w-4 h-4 border-2 border-t-blue-400 rounded-full animate-spin" style={{ borderColor: "var(--border)" }} />
)}
</div>
{/* Tags */}
{allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-5">
<Hash size={14} style={{ color: "var(--text-faint)" }} />
{tagFilter && (
<button
onClick={() => setTagFilter(null)}
className="px-2 py-0.5 rounded-full text-xs font-medium text-white bg-blue-500 hover:bg-blue-600 transition-colors"
>
{tagFilter}
</button>
)}
{allTags.map(([tag, count]) => (
<button
key={tag}
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
className="px-2 py-0.5 rounded-full text-xs transition-colors"
style={{
background: tagFilter === tag ? "rgba(59,130,246,0.2)" : "var(--bg-secondary)",
color: tagFilter === tag ? "#3b82f6" : "var(--text-muted)",
border: `1px solid ${tagFilter === tag ? "#3b82f6" : "var(--border)"}`,
}}
>
{tag}
<span className="ml-1 opacity-50">{count}</span>
</button>
))}
</div>
)}
{/* Month groups */}
{months.length === 0 && !loading && (
<div className="text-center py-16" style={{ color: "var(--text-faint)" }}>
<Calendar size={40} className="mx-auto mb-3 opacity-30" />
<p>{t("timeline.empty")}</p>
</div>
)}
<div className="space-y-3">
{months.map(group => (
<div key={group.key} className="rounded-lg overflow-hidden" style={{ border: "1px solid var(--border)" }}>
{/* Month header */}
<button
onClick={() => toggle(group.key)}
className="w-full flex items-center justify-between px-4 py-2.5 text-sm font-semibold transition-colors"
style={{ background: "var(--bg-secondary)", color: "var(--text-primary)" }}
onMouseEnter={e => e.currentTarget.style.background = "var(--bg-hover)"}
onMouseLeave={e => e.currentTarget.style.background = "var(--bg-secondary)"}
>
<div className="flex items-center gap-2">
{expanded.has(group.key)
? <CaretDown size={16} style={{ color: "var(--text-faint)" }} />
: <CaretRight size={16} style={{ color: "var(--text-faint)" }} />}
{group.label}
</div>
<div className="flex items-center gap-3 text-xs font-normal" style={{ color: "var(--text-faint)" }}>
<span>{fmtChars(group.totalChars)}</span>
<span className="px-1.5 py-0.5 rounded-full" style={{ background: "var(--bg-tertiary)" }}>
{group.entries.length}
</span>
</div>
</button>
{/* Entries */}
{expanded.has(group.key) && (
<div>
{group.entries.map((entry, i) => (
<button
key={entry.path}
onClick={() => onOpenFile(entry.path)}
className="w-full text-left flex items-start gap-3 px-4 py-3 transition-colors"
style={{ borderTop: i > 0 ? "1px solid var(--border)" : undefined }}
onMouseEnter={e => e.currentTarget.style.background = "var(--bg-hover)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
{/* Date badge */}
<div className="shrink-0 w-12 text-center pt-0.5">
<div className="text-base font-bold" style={{ color: "var(--text-primary)" }}>
{entry.date.slice(8)}
</div>
<div className="text-[10px]" style={{ color: "var(--text-faint)" }}>
{weekday(entry.date)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" style={{ color: "var(--text-primary)" }}>
{entry.title}
</div>
<div className="text-xs mt-0.5 truncate" style={{ color: "var(--text-muted)" }}>
{entry.preview}
</div>
{entry.tags.length > 0 && (
<div className="flex gap-1 mt-1">
{entry.tags.map(tag => (
<span
key={tag}
className="text-[10px] px-1.5 py-0.5 rounded"
style={{ background: "var(--bg-tertiary)", color: "var(--text-faint)" }}
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Char count */}
<div className="shrink-0 text-[11px] pt-1" style={{ color: "var(--text-faint)" }}>
{fmtChars(entry.charCount)}
</div>
</button>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useState, useCallback, useEffect } from "react";
import { fetchAgents, setCurrentAgent, type AgentInfo } from "../api";
const STORAGE_KEY = "memory-viewer-selected-agent";
export function useAgents() {
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string>(() => {
return localStorage.getItem(STORAGE_KEY) || "default";
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadAgents = useCallback(async () => {
try {
setLoading(true);
const data = await fetchAgents();
setAgents(data);
setError(null);
// Validate selected agent still exists
const exists = data.find((a) => a.id === selectedAgentId);
if (!exists && data.length > 0) {
// Fall back to default or first agent
const defaultAgent = data.find((a) => a.id === "default") || data[0];
setSelectedAgentId(defaultAgent.id);
setCurrentAgent(defaultAgent.id);
localStorage.setItem(STORAGE_KEY, defaultAgent.id);
} else if (exists) {
setCurrentAgent(selectedAgentId);
}
} catch (e: any) {
setError(e.message || "Failed to load agents");
} finally {
setLoading(false);
}
}, [selectedAgentId]);
const selectAgent = useCallback((agentId: string) => {
setSelectedAgentId(agentId);
setCurrentAgent(agentId);
localStorage.setItem(STORAGE_KEY, agentId);
}, []);
useEffect(() => {
loadAgents();
}, [loadAgents]);
const selectedAgent = agents.find((a) => a.id === selectedAgentId) || agents[0];
return {
agents,
selectedAgent,
selectedAgentId,
selectAgent,
loading,
error,
refresh: loadAgents,
};
}

View File

@@ -0,0 +1,120 @@
import { useState, useCallback, useEffect, useRef } from "react";
export interface BotConnection {
id: string;
name: string;
url: string; // "" for local
token?: string;
isLocal?: boolean;
}
const STORAGE_KEY = "memory-viewer-connections";
const ACTIVE_KEY = "memory-viewer-active-connection";
const LOCAL_CONNECTION: BotConnection = {
id: "local",
name: "Local",
url: "",
isLocal: true,
};
function loadConnections(): BotConnection[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const list = JSON.parse(raw) as BotConnection[];
// Ensure local is always first
if (!list.find((c) => c.id === "local")) {
list.unshift(LOCAL_CONNECTION);
}
return list;
}
} catch { /* ignore */ }
return [LOCAL_CONNECTION];
}
function saveConnections(list: BotConnection[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
}
export function useConnections() {
const [connections, setConnections] = useState<BotConnection[]>(loadConnections);
const [activeId, setActiveId] = useState<string>(() => {
return localStorage.getItem(ACTIVE_KEY) || "local";
});
const [statuses, setStatuses] = useState<Record<string, boolean>>({});
const intervalRef = useRef<ReturnType<typeof setInterval>>();
const active = connections.find((c) => c.id === activeId) || connections[0];
const addConnection = useCallback((conn: Omit<BotConnection, "id">) => {
const id = `bot-${Date.now()}`;
setConnections((prev) => {
const next = [...prev, { ...conn, id }];
saveConnections(next);
return next;
});
}, []);
const updateConnection = useCallback((id: string, updates: Partial<BotConnection>) => {
setConnections((prev) => {
const next = prev.map((c) => (c.id === id ? { ...c, ...updates } : c));
saveConnections(next);
return next;
});
}, []);
const removeConnection = useCallback((id: string) => {
if (id === "local") return;
setConnections((prev) => {
const next = prev.filter((c) => c.id !== id);
saveConnections(next);
return next;
});
setActiveId((prev) => (prev === id ? "local" : prev));
}, []);
const switchTo = useCallback((id: string) => {
setActiveId(id);
localStorage.setItem(ACTIVE_KEY, id);
}, []);
// Check connection statuses
const checkStatuses = useCallback(async () => {
const results: Record<string, boolean> = {};
await Promise.all(
connections.map(async (conn) => {
if (conn.isLocal) {
results[conn.id] = true;
return;
}
try {
const r = await fetch(`${conn.url.replace(/\/+$/, "")}/api/system`, {
signal: AbortSignal.timeout(5000),
});
results[conn.id] = r.ok;
} catch {
results[conn.id] = false;
}
})
);
setStatuses(results);
}, [connections]);
useEffect(() => {
checkStatuses();
intervalRef.current = setInterval(checkStatuses, 30000);
return () => clearInterval(intervalRef.current);
}, [checkStatuses]);
return {
connections,
active,
statuses,
addConnection,
updateConnection,
removeConnection,
switchTo,
checkStatuses,
};
}

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, useState, useCallback, useMemo } from "react";
import { type Locale, translate } from "../i18n";
interface LocaleContextValue {
locale: Locale;
setLocale: (l: Locale) => void;
toggleLocale: () => void;
t: (key: string) => string;
}
function getInitialLocale(): Locale {
try {
const saved = localStorage.getItem("mv-locale");
if (saved === "zh" || saved === "en") return saved;
} catch {}
return "en";
}
export const LocaleContext = createContext<LocaleContextValue>(null!);
export function useLocaleState() {
const [locale, setLocaleRaw] = useState<Locale>(getInitialLocale);
const setLocale = useCallback((l: Locale) => {
setLocaleRaw(l);
try { localStorage.setItem("mv-locale", l); } catch {}
}, []);
const toggleLocale = useCallback(() => {
setLocale(locale === "en" ? "zh" : "en");
}, [locale, setLocale]);
const t = useCallback((key: string) => translate(key, locale), [locale]);
return useMemo(() => ({ locale, setLocale, toggleLocale, t }), [locale, setLocale, toggleLocale, t]);
}
export function useLocale() {
return useContext(LocaleContext);
}

View File

@@ -0,0 +1,75 @@
import { useState, useEffect, useCallback, useRef } from "react";
const SIDEBAR_WIDTH_KEY = "memory-viewer-sidebar-width";
const DEFAULT_WIDTH = 320;
const MIN_WIDTH = 200;
const MAX_WIDTH = 600;
export function useResizableSidebar() {
const [width, setWidth] = useState(() => {
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
return saved ? Number(saved) : DEFAULT_WIDTH;
});
const isDragging = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);
useEffect(() => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(width));
}, [width]);
const handleMove = useCallback((clientX: number) => {
if (!isDragging.current) return;
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + clientX - startX.current));
setWidth(newWidth);
}, []);
const handleEnd = useCallback(() => {
isDragging.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}, []);
// Mouse events
const onMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
startX.current = e.clientX;
startWidth.current = width;
const onMouseMove = (e: MouseEvent) => handleMove(e.clientX);
const onMouseUp = () => {
handleEnd();
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}, [width, handleMove, handleEnd]);
// Touch events
const onTouchStart = useCallback((e: React.TouchEvent) => {
if (e.touches.length !== 1) return;
isDragging.current = true;
startX.current = e.touches[0].clientX;
startWidth.current = width;
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
handleMove(e.touches[0].clientX);
};
const onTouchEnd = () => {
handleEnd();
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", onTouchEnd);
};
document.addEventListener("touchmove", onTouchMove, { passive: true });
document.addEventListener("touchend", onTouchEnd);
}, [width, handleMove, handleEnd]);
return { width, onMouseDown, onTouchStart, MIN_WIDTH, MAX_WIDTH };
}

View File

@@ -0,0 +1,23 @@
import { useState, useEffect, createContext, useContext } from "react";
const SensitiveContext = createContext({ hidden: true, toggle: () => {} });
export function useSensitiveState() {
const [hidden, setHidden] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("sensitive-hidden") !== "false";
}
return true;
});
useEffect(() => {
localStorage.setItem("sensitive-hidden", String(hidden));
}, [hidden]);
const toggle = () => setHidden((h) => !h);
return { hidden, toggle };
}
export const SensitiveProvider = SensitiveContext.Provider;
export const useSensitive = () => useContext(SensitiveContext);

View File

@@ -0,0 +1,28 @@
import { useState, useEffect } from "react";
type Theme = "dark" | "light";
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("theme") as Theme) || "dark";
}
return "dark";
});
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
root.classList.remove("light");
} else {
root.classList.add("light");
root.classList.remove("dark");
}
localStorage.setItem("theme", theme);
}, [theme]);
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
return { theme, toggle };
}

View File

@@ -0,0 +1,44 @@
import { useEffect, useRef, useCallback } from "react";
type MessageHandler = (data: { type: string; event: string; path: string }) => void;
/**
* Auto-reconnecting WebSocket hook for live file-change notifications.
* Supports dynamic URL for remote connections.
*/
export function useWebSocket(onMessage: MessageHandler, remoteBaseUrl = "") {
const wsRef = useRef<WebSocket | null>(null);
const cbRef = useRef(onMessage);
cbRef.current = onMessage;
const connect = useCallback(() => {
let wsUrl: string;
if (remoteBaseUrl) {
// Convert http(s)://host:port to ws(s)://host:port/ws
const url = new URL(remoteBaseUrl);
const proto = url.protocol === "https:" ? "wss:" : "ws:";
wsUrl = `${proto}//${url.host}/ws`;
} else {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
wsUrl = `${proto}//${location.host}/ws`;
}
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {
try {
cbRef.current(JSON.parse(e.data));
} catch { /* ignore bad json */ }
};
ws.onclose = () => {
setTimeout(connect, 3000);
};
wsRef.current = ws;
}, [remoteBaseUrl]);
useEffect(() => {
connect();
return () => wsRef.current?.close();
}, [connect]);
}

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from "react";
const ZOOM_KEY = "memory-viewer-zoom";
const ZOOM_LEVELS = [75, 80, 90, 100, 110, 125, 150];
const DEFAULT_ZOOM = 100;
export function useZoom() {
const [zoom, setZoom] = useState(() => {
const saved = localStorage.getItem(ZOOM_KEY);
return saved ? Number(saved) : DEFAULT_ZOOM;
});
useEffect(() => {
localStorage.setItem(ZOOM_KEY, String(zoom));
}, [zoom]);
return { zoom, setZoom, ZOOM_LEVELS };
}

244
memory-viewer/src/i18n.ts Normal file
View File

@@ -0,0 +1,244 @@
export type Locale = "en" | "zh";
const dict: Record<string, Record<Locale, string>> = {
// Sidebar
"sidebar.agentConfig": { en: "Agent Config", zh: "智能体配置" },
"sidebar.coreFiles": { en: "Core Files", zh: "核心文件" },
"sidebar.dailyNotes": { en: "Daily Notes", zh: "每日日记" },
"sidebar.today": { en: "Today", zh: "今日日记" },
"sidebar.quickAccess": { en: "Quick Access", zh: "快速访问" },
// Skills Page
"skills.title": { en: "Skills", zh: "技能" },
"skills.description": { en: "Agent capabilities and extensions", zh: "智能体能力与扩展" },
"skills.empty": { en: "No skills found", zh: "未找到技能" },
"sidebar.skills": { en: "Skills", zh: "技能" },
"sidebar.files": { en: "Files", zh: "文件" },
"sidebar.search": { en: "Search…", zh: "搜索…" },
"sidebar.footer": { en: "Silicon Dawn · Memory Viewer", zh: "Silicon Dawn · 记忆查看器" },
"sidebar.showSensitive": { en: "Show sensitive content", zh: "显示敏感内容" },
"sidebar.hideSensitive": { en: "Hide sensitive content", zh: "隐藏敏感内容" },
"sidebar.lightMode": { en: "Switch to light mode", zh: "切换到浅色模式" },
"sidebar.darkMode": { en: "Switch to dark mode", zh: "切换到深色模式" },
// Agent Status
"agent.title": { en: "Agent Status", zh: "智能体状态" },
"agent.gateway": { en: "Gateway", zh: "网关" },
"agent.models": { en: "Models", zh: "模型" },
"agent.heartbeat": { en: "Heartbeat", zh: "心跳" },
"agent.config": { en: "Safe Config", zh: "安全配置" },
"agent.running": { en: "Running", zh: "运行中" },
"agent.stopped": { en: "Stopped", zh: "已停止" },
"agent.uptime": { en: "uptime", zh: "运行时间" },
"agent.port": { en: "Port", zh: "端口" },
"agent.pid": { en: "PID", zh: "PID" },
"agent.mode": { en: "Mode", zh: "模式" },
"agent.primary": { en: "Primary Model", zh: "主模型" },
"agent.ready": { en: "Ready to inference", zh: "就绪" },
"agent.lastActivity": { en: "Last activity", zh: "最近活动" },
"agent.noHeartbeat": { en: "No heartbeat data", zh: "无心跳数据" },
"agent.channel": { en: "channel", zh: "通道" },
"agent.loading": { en: "Loading status...", zh: "加载状态..." },
"agent.error": { en: "Failed to load status. Ensure the server is running on the same machine as the agent.", zh: "加载失败。请确保服务器与 Agent 运行在同一台机器上。" },
// Dashboard
"dashboard.title": { en: "Dashboard", zh: "仪表盘" },
"dashboard.uptime": { en: "Uptime", zh: "运行时间" },
"dashboard.memory": { en: "Memory", zh: "内存" },
"dashboard.load": { en: "Load", zh: "负载" },
"dashboard.files": { en: "Files", zh: "文件" },
"dashboard.mdTracked": { en: ".md files tracked", zh: "个 .md 文件" },
"dashboard.todayMemory": { en: "Today's Memory", zh: "今日记忆" },
"dashboard.viewFull": { en: "View full →", zh: "查看全部 →" },
"dashboard.noMemoryToday": { en: "No memory entries for today yet.", zh: "今天还没有记忆条目。" },
"dashboard.recentlyModified": { en: "Recently Modified", zh: "最近修改" },
"dashboard.memoryByMonth": { en: "Memory by Month", zh: "月度记忆" },
"dashboard.quickAccess": { en: "Quick Access", zh: "快速访问" },
"dashboard.loading": { en: "Loading…", zh: "加载中…" },
"dashboard.noFiles": { en: "No files found.", zh: "未找到文件。" },
"dashboard.noMemoryFiles": { en: "No memory files found.", zh: "未找到记忆文件。" },
"dashboard.characters": { en: "characters", zh: "个字符" },
"dashboard.justNow": { en: "just now", zh: "刚刚" },
"dashboard.minAgo": { en: "min ago", zh: "分钟前" },
"dashboard.hAgo": { en: "h ago", zh: "小时前" },
"dashboard.dAgo": { en: "d ago", zh: "天前" },
// FileViewer
"file.edit": { en: "Edit", zh: "编辑" },
"file.save": { en: "Save", zh: "保存" },
"file.cancel": { en: "Cancel", zh: "取消" },
"file.saved": { en: "Saved", zh: "已保存" },
"file.failedToLoad": { en: "Failed to load", zh: "加载失败" },
"file.saveFailed": { en: "Save failed", zh: "保存失败" },
"file.words": { en: "words", zh: "词" },
"file.discardChanges": { en: "Discard unsaved changes?", zh: "放弃未保存的更改?" },
"file.reloaded": { en: "Reloaded from server", zh: "已从服务器重新加载" },
"changelog.title": { en: "Changelog", zh: "更新日志" },
"changelog.back": { en: "Back", zh: "返回" },
"sidebar.changelog": { en: "Changelog", zh: "更新日志" },
"file.conflictTitle": { en: "File Conflict", zh: "文件冲突" },
"file.conflictDesc": { en: "This file was modified on disk while you were editing. Your changes have not been saved.", zh: "文件在编辑期间被其他程序修改,您的更改尚未保存。" },
"file.conflictTime": { en: "Modified at", zh: "修改时间" },
"file.conflictOverwrite": { en: "Overwrite", zh: "覆盖" },
"file.conflictReload": { en: "Reload", zh: "重新加载" },
"file.loading": { en: "Loading…", zh: "加载中…" },
"file.backToTop": { en: "Back to top", zh: "返回顶部" },
"file.copy": { en: "Copy", zh: "复制" },
// Search
"search.placeholder": { en: "Search all memory files…", zh: "搜索所有记忆文件…" },
"search.noResults": { en: "No results found for", zh: "未找到相关结果:" },
"search.files": { en: "files", zh: "个文件" },
"search.matches": { en: "matches", zh: "个匹配" },
"search.results": { en: "results", zh: "个结果" },
"settings.title": { en: "Settings", zh: "设置" },
"settings.embedding.title": { en: "Vector Search", zh: "向量搜索" },
"settings.embedding.description": { en: "Configure an OpenAI-compatible embedding API for semantic search. Supports OpenAI, DeepSeek, SiliconFlow, Ollama, etc.", zh: "配置 OpenAI 兼容的 Embedding API 进行语义搜索。支持 OpenAI、DeepSeek、SiliconFlow、Ollama 等。" },
"settings.embedding.model": { en: "Model", zh: "模型" },
"settings.testConnection": { en: "Test Connection", zh: "测试连接" },
"settings.testing": { en: "Testing…", zh: "测试中…" },
"settings.save": { en: "Save", zh: "保存" },
"settings.saving": { en: "Saving…", zh: "保存中…" },
"settings.saved": { en: "Saved", zh: "已保存" },
"settings.saveFailed": { en: "Save failed", zh: "保存失败" },
"search.modeText": { en: "Text", zh: "文本" },
"search.modeSemantic": { en: "Semantic", zh: "语义" },
"search.relevance": { en: "relevance", zh: "相关度" },
// Connections
"connections.title": { en: "Connections", zh: "连接管理" },
"connections.add": { en: "Add", zh: "添加" },
"connections.addNew": { en: "Add Connection", zh: "添加连接" },
"connections.edit": { en: "Edit Connection", zh: "编辑连接" },
"connections.save": { en: "Save", zh: "保存" },
"connections.cancel": { en: "Cancel", zh: "取消" },
"connections.refresh": { en: "Refresh", zh: "刷新" },
"connections.active": { en: "Active", zh: "当前连接" },
"connections.namePlaceholder": { en: "Name (e.g. Bot 01)", zh: "名称(如 Bot 01" },
"connections.urlPlaceholder": { en: "URL (e.g. http://host:8901)", zh: "URL如 http://host:8901" },
"connections.tokenPlaceholder": { en: "Token (optional)", zh: "Token可选" },
"connections.switchBot": { en: "Switch Bot", zh: "切换 Bot" },
"connections.manage": { en: "Manage", zh: "管理" },
"connections.modeDirect": { en: "Direct", zh: "直连" },
"connections.modeGateway": { en: "Gateway", zh: "网关" },
"connections.gatewayUrl": { en: "Gateway URL", zh: "网关地址" },
"connections.gatewayUrlPlaceholder": { en: "Gateway URL (e.g. http://host:18789)", zh: "网关地址(如 http://host:18789" },
"connections.gatewayToken": { en: "Gateway Token", zh: "网关 Token" },
"connections.gatewayTokenPlaceholder": { en: "Gateway API Token", zh: "网关 API Token" },
"connections.mvPort": { en: "MV Port", zh: "MV 端口" },
"connections.mvPortPlaceholder": { en: "Memory Viewer Port (default 8901)", zh: "Memory Viewer 端口(默认 8901" },
// Bootstrap
"bootstrap.checking": { en: "Checking if Memory Viewer is running…", zh: "正在检测 Memory Viewer 是否运行…" },
"bootstrap.alreadyRunning": { en: "Memory Viewer is already running! Connection saved.", zh: "Memory Viewer 已在运行!连接已保存。" },
"bootstrap.notInstalled": { en: "Remote bot doesn't have Memory Viewer installed.", zh: "远程 Bot 未安装 Memory Viewer。" },
"bootstrap.installPrompt": { en: "Would you like to install it via Gateway API? This will send commands to the remote bot.", zh: "是否通过网关 API 安装?这将向远程 Bot 发送安装指令。" },
"bootstrap.install": { en: "Install", zh: "安装" },
"bootstrap.installing": { en: "Installing Memory Viewer via Gateway…", zh: "正在通过网关安装 Memory Viewer…" },
"bootstrap.botResponse": { en: "Bot Response", zh: "Bot 回复" },
"bootstrap.verifying": { en: "Verifying installation…", zh: "正在验证安装…" },
"bootstrap.success": { en: "Memory Viewer installed and connected successfully!", zh: "Memory Viewer 安装成功并已连接!" },
"bootstrap.failed": { en: "Could not connect after installation. The bot may need more time.", zh: "安装后无法连接。Bot 可能需要更多时间。" },
"bootstrap.retry": { en: "Retry Verification", zh: "重试验证" },
"bootstrap.close": { en: "Close", zh: "关闭" },
"bootstrap.error": { en: "Error", zh: "错误" },
"bootstrap.done": { en: "Done", zh: "完成" },
// Heatmap
"heatmap.activeDays": { en: "active days", zh: "个活跃日" },
"heatmap.total": { en: "total", zh: "总计" },
"heatmap.perDay": { en: "/day", zh: "/天" },
"heatmap.less": { en: "Less", zh: "少" },
"heatmap.more": { en: "More", zh: "多" },
"heatmap.noData": { en: "No data", zh: "无数据" },
// Timeline
"sidebar.timeline": { en: "Timeline", zh: "时间线" },
"timeline.title": { en: "Memory Timeline", zh: "记忆时间线" },
"timeline.loading": { en: "Loading memories…", zh: "正在加载记忆…" },
"timeline.entries": { en: "entries", zh: "篇日记" },
"timeline.words": { en: "chars", zh: "字符" },
"timeline.tags": { en: "tags", zh: "标签" },
"timeline.filterByTag": { en: "Filter by tag:", zh: "按标签筛选:" },
"timeline.noResults": { en: "No entries found with this tag", zh: "未找到包含此标签的日记" },
"timeline.empty": { en: "No memory entries found", zh: "未找到记忆日记" },
"timeline.chars": { en: "chars", zh: "字" },
"timeline.months": { en: "months", zh: "个月" },
// Tags
"sidebar.tags": { en: "Tags", zh: "标签" },
"tags.title": { en: "Tags", zh: "标签" },
"tags.subtitle": { en: "Browse memories by topic", zh: "按主题浏览记忆" },
"tags.totalTags": { en: "tags", zh: "标签" },
"tags.totalFiles": { en: "files", zh: "文件" },
"tags.avgPerTag": { en: "avg", zh: "平均" },
"tags.popular": { en: "Popular Tags", zh: "热门标签" },
"tags.clear": { en: "Clear filter", zh: "清除筛选" },
"tags.showing": { en: "Showing tag:", zh: "显示标签:" },
"tags.files": { en: "files", zh: "个文件" },
"tags.tags": { en: "tags", zh: "个标签" },
"tags.noFiles": { en: "No files found with this tag", zh: "没有找到相关文件" },
"tags.empty": { en: "No tags yet", zh: "暂无标签" },
"tags.emptyHint": { en: "Use ## headers or #hashtags in Markdown to create tags", zh: "在 Markdown 中使用 ## 标题 或 #标签 来创建标签" },
// Settings
"settings.title": { en: "Settings", zh: "设置" },
"sidebar.settings": { en: "Settings", zh: "设置" },
"settings.embedding.title": { en: "Embedding Search", zh: "向量搜索" },
"settings.embedding.enable": { en: "Enable Embedding Search", zh: "启用向量搜索" },
"settings.embedding.enableDesc": { en: "Use OpenAI Embeddings API for semantic search", zh: "使用 OpenAI Embeddings API 进行语义搜索" },
"settings.embedding.apiUrl": { en: "API URL", zh: "API 地址" },
"settings.embedding.apiUrlDesc": { en: "OpenAI Embeddings API or compatible endpoint", zh: "OpenAI Embeddings API 或兼容的端点" },
"settings.embedding.apiKey": { en: "API Key", zh: "API 密钥" },
"settings.embedding.apiKeyDesc": { en: "OpenAI API key (sk-...)", zh: "OpenAI API 密钥 (sk-...)" },
"settings.embedding.apiKeySet": { en: "API key is set", zh: "API 密钥已设置" },
"settings.embedding.model": { en: "Model", zh: "模型" },
"settings.embedding.modelDesc": { en: "Embedding model name, e.g. text-embedding-3-small", zh: "Embedding 模型名称,如 text-embedding-3-small" },
"settings.embedding.test": { en: "Test Connection", zh: "测试连接" },
"settings.embedding.testing": { en: "Testing...", zh: "测试中..." },
"settings.save": { en: "Save Settings", zh: "保存设置" },
"settings.saving": { en: "Saving...", zh: "保存中..." },
// Cron
"sidebar.cron": { en: "Cron Jobs", zh: "定时任务" },
"cron.title": { en: "Cron Jobs", zh: "定时任务" },
"cron.subtitle": { en: "Manage scheduled tasks", zh: "管理定时任务" },
"cron.total": { en: "Total", zh: "总计" },
"cron.enabled": { en: "Enabled", zh: "已启用" },
"cron.disabled": { en: "Disabled", zh: "已禁用" },
"cron.refresh": { en: "Refresh", zh: "刷新" },
"cron.enable": { en: "Enable", zh: "启用" },
"cron.disable": { en: "Disable", zh: "禁用" },
"cron.runNow": { en: "Run Now", zh: "立即运行" },
"cron.history": { en: "History", zh: "历史" },
"cron.runHistory": { en: "Run History", zh: "运行历史" },
"cron.loading": { en: "Loading…", zh: "加载中…" },
"cron.noRuns": { en: "No run history", zh: "暂无运行记录" },
"cron.empty": { en: "No cron jobs configured", zh: "暂无定时任务" },
"cron.justNow": { en: "just now", zh: "刚刚" },
"cron.soonLabel": { en: "soon", zh: "即将" },
"cron.minAgo": { en: "m ago", zh: "分钟前" },
"cron.minLater": { en: "m later", zh: "分钟后" },
"cron.hAgo": { en: "h ago", zh: "小时前" },
"cron.hLater": { en: "h later", zh: "小时后" },
"cron.dAgo": { en: "d ago", zh: "天前" },
"cron.dLater": { en: "d later", zh: "天后" },
// Months
"month.01": { en: "Jan", zh: "1月" },
"month.02": { en: "Feb", zh: "2月" },
"month.03": { en: "Mar", zh: "3月" },
"month.04": { en: "Apr", zh: "4月" },
"month.05": { en: "May", zh: "5月" },
"month.06": { en: "Jun", zh: "6月" },
"month.07": { en: "Jul", zh: "7月" },
"month.08": { en: "Aug", zh: "8月" },
"month.09": { en: "Sep", zh: "9月" },
"month.10": { en: "Oct", zh: "10月" },
"month.11": { en: "Nov", zh: "11月" },
"month.12": { en: "Dec", zh: "12月" },
};
export function translate(key: string, locale: Locale): string {
return dict[key]?.[locale] ?? key;
}

444
memory-viewer/src/index.css Normal file
View File

@@ -0,0 +1,444 @@
@import "tailwindcss";
@theme {
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
/* ===== Theme System ===== */
/* Light default: Maple (warm, soft, nature-inspired) */
:root, .theme-maple-light {
--bg-primary: #faf8f5;
--bg-secondary: #ffffff;
--bg-tertiary: #f5f0eb;
--bg-hover: rgba(139,90,43,0.04);
--bg-active: rgba(180,83,9,0.08);
--border: #e8ddd0;
--border-light: #f0e8de;
--text-primary: #1c1410;
--text-secondary: #44362a;
--text-muted: #7c6a58;
--text-faint: #a89580;
--code-bg: #f5efe8;
--code-text: #9a3412;
--pre-bg: #faf6f1;
--pre-border: #e8ddd0;
--pre-text: #44362a;
--link: #b45309;
--link-hover: #92400e;
--blockquote-border: rgba(180,83,9,0.35);
--blockquote-text: #7c6a58;
--blockquote-bg: rgba(245,240,235,0.6);
--th-bg: #f5efe8;
--tr-hover: rgba(139,90,43,0.04);
--scrollbar-thumb: rgba(139,90,43,0.2);
--scrollbar-hover: rgba(139,90,43,0.35);
--toast-bg: #292017;
--toast-text: #fff;
--editor-bg: rgba(245,240,235,0.5);
--ring: rgba(180,83,9,0.4);
/* Markdown headings */
--md-h1: #78350f;
--md-h2: #92400e;
--md-h3: #a16207;
--md-h1-border: #d6c4a8;
--md-h2-border: #e8ddd0;
--md-strong: #1c1410;
--md-em: #92400e;
--md-hr: #d6c4a8;
}
/* Dark default: Material (Google Material Design inspired) */
.dark, .theme-material-dark {
--bg-primary: #0f111a;
--bg-secondary: #1a1c2b;
--bg-tertiary: #242637;
--bg-hover: rgba(130,170,255,0.04);
--bg-active: rgba(130,170,255,0.12);
--border: #2a2d3e;
--border-light: rgba(42,45,62,0.6);
--text-primary: #eeffff;
--text-secondary: #b0bec5;
--text-muted: #78909c;
--text-faint: #546e7a;
--code-bg: #1d1f31;
--code-text: #c3e88d;
--pre-bg: #0f111a;
--pre-border: #2a2d3e;
--pre-text: #b0bec5;
--link: #82aaff;
--link-hover: #a6c4ff;
--blockquote-border: rgba(130,170,255,0.4);
--blockquote-text: #78909c;
--blockquote-bg: rgba(36,38,55,0.5);
--th-bg: rgba(36,38,55,0.6);
--tr-hover: rgba(15,17,26,0.5);
--scrollbar-thumb: rgba(84,110,122,0.5);
--scrollbar-hover: rgba(84,110,122,0.7);
--toast-bg: #242637;
--toast-text: #eeffff;
--editor-bg: rgba(15,17,26,0.6);
--ring: rgba(130,170,255,0.5);
/* Markdown headings */
--md-h1: #c792ea;
--md-h2: #82aaff;
--md-h3: #ffcb6b;
--md-h1-border: #3a3d52;
--md-h2-border: rgba(42,45,62,0.6);
--md-strong: #eeffff;
--md-em: #f78c6c;
--md-hr: #3a3d52;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 16px;
}
/* Tesla mode — large touch-friendly UI for car displays */
.tesla-mode {
font-size: 24px;
}
.tesla-mode .markdown-body { font-size: 24px !important; line-height: 1.9; }
.tesla-mode .markdown-body h1 { font-size: 2.4rem; }
.tesla-mode .markdown-body h2 { font-size: 2rem; }
.tesla-mode .markdown-body h3 { font-size: 1.6rem; }
.tesla-mode .markdown-body h4 { font-size: 1.3rem; }
.tesla-mode .markdown-body code { font-size: 0.85em; padding: 4px 8px; }
.tesla-mode .markdown-body pre { padding: 20px; font-size: 0.85em; }
.tesla-mode .markdown-body li { margin-bottom: 6px; }
.tesla-mode .sidebar-header { padding: 1.2rem 1.5rem; }
.tesla-mode .sidebar-header button { font-size: 1.3rem !important; }
.tesla-mode .sidebar-header svg { width: 24px !important; height: 24px !important; }
.tesla-mode .sidebar-footer { font-size: 1rem; padding: 1rem 1.5rem; }
.tesla-mode .sidebar button,
.tesla-mode .sidebar a { min-height: 56px; padding: 12px 16px !important; font-size: 1.15rem; }
.tesla-mode .btn-primary, .tesla-mode .btn-secondary { min-height: 56px; padding: 14px 24px !important; font-size: 1.15rem; }
.tesla-mode button svg { min-width: 24px; min-height: 24px; }
.tesla-mode button { min-height: 48px; min-width: 48px; }
.tesla-mode input[type="text"], .tesla-mode input[type="search"] { min-height: 56px; font-size: 1.15rem; padding: 14px 18px; }
.tesla-mode nav a, .tesla-mode nav button, .tesla-mode [role="tab"] { min-height: 56px; padding: 12px 20px !important; font-size: 1.15rem; }
/* Buttons */
.btn-primary {
@apply px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 text-white font-medium rounded-lg transition-colors;
}
.btn-secondary {
padding: 6px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: 8px;
transition: all 0.15s;
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--text-faint);
}
/* Sidebar */
.sidebar {
background: var(--bg-secondary);
border-color: var(--border);
}
.sidebar-header {
border-color: var(--border);
}
.sidebar-footer {
border-color: var(--border);
color: var(--text-faint);
}
.sidebar-section-title {
color: var(--text-faint);
}
.sidebar-item {
color: var(--text-muted);
transition: all 0.15s;
}
.sidebar-item:hover {
background: var(--bg-hover);
color: var(--text-secondary);
}
.sidebar-item-active {
background: var(--bg-active);
color: var(--link);
}
.search-trigger {
background: var(--bg-hover);
border: 1px solid var(--border-light);
color: var(--text-faint);
}
.search-trigger:hover {
color: var(--text-muted);
border-color: var(--text-faint);
}
/* Markdown — optimized for readability */
.markdown-body {
color: var(--text-secondary);
line-height: 1.85;
letter-spacing: 0.01em;
font-size: 15px;
}
.markdown-body h1 {
@apply text-2xl font-bold pb-3 mb-5 mt-10 first:mt-0;
color: var(--md-h1, var(--text-primary));
border-bottom: 2px solid var(--md-h1-border, var(--border));
line-height: 1.3;
}
.markdown-body h2 {
@apply text-xl font-semibold pb-2 mb-4 mt-8;
color: var(--md-h2, var(--text-primary));
border-bottom: 1px solid var(--md-h2-border, var(--border-light));
line-height: 1.35;
}
.markdown-body h3 {
@apply text-lg font-semibold mb-3 mt-6;
color: var(--md-h3, var(--text-primary));
line-height: 1.4;
}
.markdown-body h4 {
@apply text-base font-semibold mb-2 mt-5;
color: var(--text-secondary);
line-height: 1.4;
}
.markdown-body p {
@apply mb-4;
line-height: 1.85;
}
.markdown-body ul { @apply list-disc pl-6 mb-4 space-y-1.5; }
.markdown-body ol { @apply list-decimal pl-6 mb-4 space-y-1.5; }
.markdown-body li {
color: var(--text-secondary);
line-height: 1.75;
}
.markdown-body li > ul,
.markdown-body li > ol {
@apply mt-1.5 mb-0;
}
.markdown-body code {
background: var(--code-bg);
color: var(--code-text);
@apply px-1.5 py-0.5 rounded text-[0.85em] font-mono;
font-weight: 500;
}
.markdown-body pre {
background: var(--pre-bg);
border: 1px solid var(--pre-border);
font-family: var(--font-mono);
@apply rounded-xl p-4 mb-5 overflow-x-auto text-sm;
}
.markdown-body pre code { background: transparent; padding: 0; color: var(--pre-text); font-weight: normal; font-family: inherit; }
.markdown-body pre code span { background: transparent !important; }
.markdown-body code[class*="language-"] { background: transparent !important; }
.markdown-body blockquote {
border-left: 4px solid var(--blockquote-border);
padding: 0.75rem 1rem;
color: var(--blockquote-text);
background: var(--blockquote-bg, var(--bg-hover));
border-radius: 0 0.5rem 0.5rem 0;
@apply italic my-5;
}
.markdown-body blockquote p:last-child { @apply mb-0; }
.markdown-body a {
color: var(--link);
text-decoration: underline;
text-decoration-color: rgba(59,130,246,0.3);
text-underline-offset: 3px;
transition: all 0.15s;
}
.markdown-body a:hover { color: var(--link-hover); text-decoration-color: rgba(59,130,246,0.6); }
.markdown-body table { @apply w-full border-collapse mb-5 text-sm; }
.markdown-body th {
background: var(--th-bg);
border: 1px solid var(--border);
@apply px-4 py-2.5 text-left font-semibold;
color: var(--text-secondary);
}
.markdown-body td {
border: 1px solid var(--border);
@apply px-4 py-2.5;
color: var(--text-secondary);
line-height: 1.65;
}
.markdown-body tr:hover td { background: var(--tr-hover); }
.markdown-body hr { border-color: var(--md-hr, var(--border)); @apply my-8; }
.markdown-body strong { color: var(--md-strong, var(--text-primary)); @apply font-semibold; }
.markdown-body em { color: var(--md-em, var(--text-muted)); }
.markdown-body img { @apply rounded-lg max-w-full my-2; }
/* Checkbox lists */
.markdown-body input[type="checkbox"] {
@apply mr-2 rounded;
accent-color: #3b82f6;
}
/* Editor */
.editor-textarea {
background: var(--editor-bg);
color: var(--text-primary);
border: 1px solid var(--border);
@apply w-full h-full font-mono text-sm p-4 rounded-lg resize-none;
tab-size: 2;
}
.editor-textarea:focus {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
border-color: var(--ring);
}
/* Toast */
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--toast-bg);
color: var(--toast-text);
@apply px-4 py-2.5 rounded-xl shadow-2xl z-50 text-sm;
border: 1px solid var(--border);
animation: toast-in 0.3s ease-out;
}
@keyframes toast-in {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Sensitive content blur (Telegram-style) */
.sensitive-blur {
filter: blur(6px);
-webkit-filter: blur(6px);
user-select: none;
transition: filter 0.3s ease;
cursor: default;
border-radius: 4px;
padding: 0 2px;
}
.sensitive-blur:hover {
filter: blur(4px);
}
.sensitive-revealed .sensitive-blur {
filter: none !important;
-webkit-filter: none !important;
user-select: auto;
cursor: text;
}
/* Scroll to top button */
.scroll-top-btn {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 40;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
animation: toast-in 0.2s ease-out;
}
.scroll-top-btn:hover {
background: var(--bg-active);
color: var(--link);
}
/* Code block copy button */
.markdown-body pre {
position: relative;
}
.code-copy-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 6px;
border-radius: 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
z-index: 5;
display: flex;
align-items: center;
}
.markdown-body pre:hover .code-copy-btn {
opacity: 1;
}
.code-copy-btn:hover {
background: var(--bg-active);
color: var(--link);
}
/* Shiki code blocks */
.shiki-wrapper pre {
margin: 0;
border-radius: 0.75rem;
font-size: 0.85rem;
line-height: 1.7;
border: 1px solid var(--pre-border);
padding: 1rem;
overflow-x: auto;
white-space: pre;
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.shiki-wrapper pre code {
font-family: inherit;
background: transparent;
tab-size: 2;
counter-reset: line;
}
.shiki-wrapper pre code .line {
display: inline-block;
width: 100%;
}
.shiki-wrapper pre code .line::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 2.5em;
margin-right: 1em;
text-align: right;
color: var(--text-faint);
opacity: 0.5;
font-size: 0.8rem;
user-select: none;
-webkit-user-select: none;
}
/* Shiki dual theme support */
html.dark .shiki,
html.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: transparent !important;
}
html:not(.dark) .shiki,
html:not(.dark) .shiki span {
color: var(--shiki-light) !important;
background-color: transparent !important;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 9999px; }
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }
/* Superscript and subscript - use monospace for alignment */
.markdown-body sup,
.markdown-body sub {
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.65em;
vertical-align: super;
line-height: 0;
}
.markdown-body sub {
vertical-align: sub;
}

View File

@@ -0,0 +1,18 @@
import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import "./themes";
import App from "./App";
import { pluginRegistry } from "./plugins/registry";
// Expose React for external plugins
(window as any).__MV_REACT__ = React;
// Load external plugins (non-blocking)
pluginRegistry.loadExternal();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,25 @@
import { useSyncExternalStore, RefObject } from "react";
import { pluginRegistry } from "./registry";
import { PluginSlotName } from "./types";
interface PluginSlotProps {
name: PluginSlotName;
filePath: string;
content: string;
renderedRef: RefObject<HTMLElement | null>;
}
export function PluginSlot({ name, filePath, content, renderedRef }: PluginSlotProps) {
const version = useSyncExternalStore(pluginRegistry.subscribe, pluginRegistry.getSnapshot);
const components = pluginRegistry.getPluginsForSlot(name, filePath);
if (components.length === 0) return null;
return (
<>
{components.map((Component, i) => (
<Component key={`${name}-${i}-${version}`} filePath={filePath} content={content} renderedRef={renderedRef} />
))}
</>
);
}

View File

@@ -0,0 +1,116 @@
import { ComponentType } from "react";
import { MemoryViewerPlugin, PluginSlotName, SlotProps } from "./types";
import { getBaseUrl } from "../api";
const DISABLED_KEY = "mv-plugins-disabled";
class PluginRegistry {
private plugins: Map<string, MemoryViewerPlugin> = new Map();
private disabled: Set<string>;
private listeners: Set<() => void> = new Set();
private version = 0;
constructor() {
try {
const raw = localStorage.getItem(DISABLED_KEY);
this.disabled = new Set(raw ? JSON.parse(raw) : []);
} catch {
this.disabled = new Set();
}
}
private notify() {
this.version++;
this.listeners.forEach((fn) => fn());
}
private persistDisabled() {
localStorage.setItem(DISABLED_KEY, JSON.stringify([...this.disabled]));
}
register(plugin: MemoryViewerPlugin) {
this.plugins.set(plugin.id, plugin);
if (!this.disabled.has(plugin.id)) {
plugin.onActivate?.();
}
this.notify();
}
unregister(id: string) {
const plugin = this.plugins.get(id);
if (plugin) {
plugin.onDeactivate?.();
this.plugins.delete(id);
this.notify();
}
}
enable(id: string) {
this.disabled.delete(id);
this.persistDisabled();
this.plugins.get(id)?.onActivate?.();
this.notify();
}
disable(id: string) {
this.disabled.add(id);
this.persistDisabled();
this.plugins.get(id)?.onDeactivate?.();
this.notify();
}
isEnabled(id: string): boolean {
return !this.disabled.has(id);
}
getAll(): MemoryViewerPlugin[] {
return [...this.plugins.values()];
}
getPluginsForSlot(slotName: PluginSlotName, filePath: string): ComponentType<SlotProps>[] {
const result: ComponentType<SlotProps>[] = [];
for (const plugin of this.plugins.values()) {
if (this.disabled.has(plugin.id)) continue;
const component = plugin.slots?.[slotName];
if (!component) continue;
if (plugin.fileFilter && !plugin.fileFilter(filePath)) continue;
result.push(component);
}
return result;
}
/** Load external plugins from server */
async loadExternal() {
try {
const res = await fetch(`${getBaseUrl()}/api/plugins`);
const list: { id: string; name: string; entry: string }[] = await res.json();
for (const info of list) {
if (this.plugins.has(info.id)) continue;
try {
const url = `${getBaseUrl()}/api/plugins/${info.id}/${info.entry}`;
const mod = await import(/* @vite-ignore */ url);
const plugin: MemoryViewerPlugin = mod.default || mod.plugin;
if (plugin?.id) {
this.register(plugin);
}
} catch (e) {
console.warn(`Failed to load external plugin "${info.id}":`, e);
}
}
} catch {
// No external plugins available
}
}
// useSyncExternalStore compatible
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): number => {
return this.version;
};
}
export const pluginRegistry = new PluginRegistry();

View File

@@ -0,0 +1,20 @@
import { RefObject, ComponentType } from "react";
export interface SlotProps {
filePath: string;
content: string;
renderedRef: RefObject<HTMLElement | null>;
}
export type PluginSlotName = 'fileviewer-toolbar' | 'fileviewer-footer' | 'sidebar-bottom';
export interface MemoryViewerPlugin {
id: string;
name: string;
version: string;
description?: string;
onActivate?: () => void;
onDeactivate?: () => void;
slots?: Partial<Record<PluginSlotName, ComponentType<SlotProps>>>;
fileFilter?: (filePath: string) => boolean;
}

View File

@@ -0,0 +1,8 @@
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Runs a cleanup after each test case (e.g. clearing jsdom)
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,84 @@
import type { MarkdownTheme, ThemeStyles } from "./types";
const SELECTOR_MAP: Record<string, string> = {
h1: "h1",
h2: "h2",
h3: "h3",
h4: "h4",
p: "p",
a: "a",
strong: "strong",
em: "em",
blockquote: "blockquote",
code: "code",
pre: "pre",
precode: "pre code",
ul: "ul",
ol: "ol",
li: "li",
table: "table",
th: "th",
td: "td",
hr: "hr",
img: "img",
};
function applyInlineStyle(el: HTMLElement, styleStr: string) {
el.setAttribute("style", styleStr);
}
export function applyThemeStyles(article: HTMLElement, theme: MarkdownTheme) {
const isDark = document.documentElement.classList.contains("dark");
const styles: ThemeStyles | undefined = (isDark && theme.darkStyles) ? theme.darkStyles : theme.styles;
if (!styles) return;
// Apply body style to article
if (styles.body) {
article.setAttribute("style", styles.body);
}
// Apply styles per selector
for (const [key, selector] of Object.entries(SELECTOR_MAP)) {
const styleStr = styles[key as keyof typeof styles];
if (!styleStr || key === "precode") continue;
// Skip shiki-generated elements: don't touch pre/code inside .shiki-wrapper
const elements = article.querySelectorAll(selector);
for (const el of elements) {
if (key === "code" && el.closest(".shiki-wrapper")) continue;
if (key === "pre" && el.closest(".shiki-wrapper")) continue;
applyInlineStyle(el as HTMLElement, styleStr);
}
}
// Special: precode
if (styles.precode) {
const precodes = article.querySelectorAll("pre code");
for (const el of precodes) {
if (el.closest(".shiki-wrapper")) continue;
applyInlineStyle(el as HTMLElement, styles.precode);
}
}
// Special: hrContent - replace hr with styled text
if (styles.hrContent) {
const hrs = article.querySelectorAll("hr");
for (const hr of hrs) {
const p = document.createElement("p");
p.textContent = styles.hrContent;
if (styles.hr) applyInlineStyle(p, styles.hr);
hr.replaceWith(p);
}
}
}
export function cleanInlineStyles(article: Element | null) {
if (!article) return;
(article as HTMLElement).removeAttribute("style");
const all = article.querySelectorAll("*");
for (const el of all) {
// Don't remove styles from shiki-generated spans
if (el.closest(".shiki-wrapper")) continue;
(el as HTMLElement).removeAttribute("style");
}
}

View File

@@ -0,0 +1,6 @@
import type { MarkdownTheme } from "../types";
export const defaultTheme: MarkdownTheme = {
id: "default",
name: "Default",
};

View File

@@ -0,0 +1,52 @@
import type { MarkdownTheme } from "../types";
export const mediumTheme: MarkdownTheme = {
id: "medium",
name: "Medium",
styles: {
body: 'font-family:Charter,"Bitstream Charter","Nimbus Roman No9 L",Georgia,"Times New Roman",serif;font-size:15px;line-height:1.75;color:rgba(36,36,36,1);letter-spacing:-0.003em;word-break:break-word;',
h1: 'font-size:24px;font-weight:700;color:rgba(36,36,36,1);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;line-height:1.22;margin:1.8em 0 0.4em;letter-spacing:-0.016em;',
h2: 'font-size:20px;font-weight:700;color:rgba(36,36,36,1);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;line-height:1.28;margin:1.6em 0 0.3em;letter-spacing:-0.012em;',
h3: 'font-size:17px;font-weight:700;color:rgba(36,36,36,1);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;line-height:1.36;margin:1.4em 0 0.2em;',
h4: 'font-size:15px;font-weight:700;color:rgba(36,36,36,1);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;margin:1.2em 0 0.15em;',
p: 'margin:0.8em 0;line-height:1.75;font-size:15px;',
a: 'color:inherit;text-decoration:underline;text-decoration-color:rgba(36,36,36,0.4);text-underline-offset:2px;',
strong: 'font-weight:700;',
em: 'font-style:italic;',
blockquote: 'border-left:3px solid rgba(36,36,36,1);padding:0 0 0 18px;margin:1.2em 0;color:rgba(36,36,36,0.8);font-style:italic;font-size:16px;line-height:1.75;',
code: 'background:rgba(0,0,0,0.05);padding:2px 5px;border-radius:3px;font-family:Menlo,Monaco,"Courier New",monospace;font-size:13px;color:rgba(36,36,36,0.9);',
pre: 'background:#f2f2f2;padding:16px 20px;border-radius:4px;overflow-x:auto;margin:1.2em 0;',
precode: 'font-family:Menlo,Monaco,"Courier New",monospace;font-size:13px;line-height:1.7;color:rgba(36,36,36,0.9);background:none;padding:0;',
ul: 'padding-left:26px;margin:0.8em 0;list-style-type:disc;',
ol: 'padding-left:26px;margin:0.8em 0;',
li: 'margin:0.3em 0;line-height:1.75;font-size:15px;',
table: 'border-collapse:collapse;width:100%;margin:1.5em 0;font-size:14px;',
th: 'border:1px solid #d0d7de;padding:6px 13px;font-weight:600;text-align:left;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;',
td: 'border:1px solid #d0d7de;padding:6px 13px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;',
hr: 'border:none;text-align:center;margin:2.5em auto;width:100%;height:1px;background:rgba(36,36,36,0.15);',
img: 'max-width:100%;height:auto;',
},
darkStyles: {
body: 'font-family:Charter,"Bitstream Charter","Nimbus Roman No9 L",Georgia,"Times New Roman",serif;font-size:15px;line-height:1.75;color:rgba(232,230,227,1);letter-spacing:-0.003em;word-break:break-word;',
h1: 'font-size:24px;font-weight:700;color:rgba(232,230,227,1);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;line-height:1.22;margin:1.8em 0 0.4em;letter-spacing:-0.016em;',
h2: 'font-size:20px;font-weight:700;color:rgba(232,230,227,1);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;line-height:1.28;margin:1.6em 0 0.3em;letter-spacing:-0.012em;',
h3: 'font-size:17px;font-weight:700;color:rgba(232,230,227,1);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;line-height:1.36;margin:1.4em 0 0.2em;',
h4: 'font-size:15px;font-weight:700;color:rgba(232,230,227,1);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;margin:1.2em 0 0.15em;',
p: 'margin:0.8em 0;line-height:1.75;font-size:15px;color:rgba(232,230,227,0.9);',
a: 'color:rgba(232,230,227,0.9);text-decoration:underline;text-decoration-color:rgba(232,230,227,0.4);text-underline-offset:2px;',
strong: 'font-weight:700;color:rgba(232,230,227,1);',
em: 'font-style:italic;color:rgba(232,230,227,0.8);',
blockquote: 'border-left:3px solid rgba(232,230,227,0.6);padding:0 0 0 18px;margin:1.2em 0;color:rgba(232,230,227,0.7);font-style:italic;font-size:16px;line-height:1.75;',
code: 'background:rgba(255,255,255,0.08);padding:2px 5px;border-radius:3px;font-family:Menlo,Monaco,"Courier New",monospace;font-size:13px;color:rgba(232,230,227,0.9);',
pre: 'background:rgba(255,255,255,0.05);padding:16px 20px;border-radius:4px;overflow-x:auto;margin:1.2em 0;',
precode: 'font-family:Menlo,Monaco,"Courier New",monospace;font-size:13px;line-height:1.7;color:rgba(232,230,227,0.9);background:none;padding:0;',
ul: 'padding-left:26px;margin:0.8em 0;list-style-type:disc;color:rgba(232,230,227,0.9);',
ol: 'padding-left:26px;margin:0.8em 0;color:rgba(232,230,227,0.9);',
li: 'margin:0.3em 0;line-height:1.75;font-size:15px;color:rgba(232,230,227,0.9);',
table: 'border-collapse:collapse;width:100%;margin:1.5em 0;font-size:14px;',
th: 'border:1px solid #3a3d52;padding:6px 13px;font-weight:600;text-align:left;background:rgba(255,255,255,0.05);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:rgba(232,230,227,1);',
td: 'border:1px solid #3a3d52;padding:6px 13px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:rgba(232,230,227,0.9);',
hr: 'border:none;text-align:center;margin:2.5em auto;width:100%;height:1px;background:rgba(232,230,227,0.15);',
img: 'max-width:100%;height:auto;',
},
};

View File

@@ -0,0 +1,9 @@
import { themeRegistry, useMarkdownTheme } from "./registry";
import { defaultTheme } from "./builtin/default";
import { mediumTheme } from "./builtin/medium";
themeRegistry.register(defaultTheme);
themeRegistry.register(mediumTheme);
export { themeRegistry, useMarkdownTheme };
export type { MarkdownTheme } from "./types";

View File

@@ -0,0 +1,67 @@
import { useSyncExternalStore, useCallback } from "react";
import type { MarkdownTheme } from "./types";
const STORAGE_KEY = "mv-md-theme";
class ThemeRegistry {
private themes = new Map<MarkdownTheme["id"], MarkdownTheme>();
private listeners = new Set<() => void>();
private currentId: string;
private snapshot = { version: 0 };
constructor() {
this.currentId = localStorage.getItem(STORAGE_KEY) || "medium";
}
register(theme: MarkdownTheme) {
this.themes.set(theme.id, theme);
this.notify();
}
get(id: string): MarkdownTheme | undefined {
return this.themes.get(id);
}
list(): MarkdownTheme[] {
return Array.from(this.themes.values());
}
getCurrent(): MarkdownTheme {
return this.themes.get(this.currentId) || this.themes.get("default") || { id: "default", name: "Default" };
}
setCurrent(id: string) {
if (this.currentId === id) return;
this.currentId = id;
localStorage.setItem(STORAGE_KEY, id);
this.notify();
}
private notify() {
this.snapshot = { version: this.snapshot.version + 1 };
this.listeners.forEach((l) => l());
}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
};
getSnapshot = () => this.snapshot;
}
export const themeRegistry = new ThemeRegistry();
export function useMarkdownTheme() {
useSyncExternalStore(themeRegistry.subscribe, themeRegistry.getSnapshot);
const setTheme = useCallback((id: string) => {
themeRegistry.setCurrent(id);
}, []);
return {
current: themeRegistry.getCurrent(),
setTheme,
themes: themeRegistry.list(),
};
}

View File

@@ -0,0 +1,33 @@
export interface ThemeStyles {
body?: string;
h1?: string;
h2?: string;
h3?: string;
h4?: string;
p?: string;
a?: string;
strong?: string;
em?: string;
blockquote?: string;
code?: string;
pre?: string;
precode?: string;
ul?: string;
ol?: string;
li?: string;
table?: string;
th?: string;
td?: string;
hr?: string;
hrContent?: string;
img?: string;
}
export interface MarkdownTheme {
id: string;
name: string;
/** Light mode styles (also used as default/only styles) */
styles?: ThemeStyles;
/** Dark mode styles — if absent, falls back to styles */
darkStyles?: ThemeStyles;
}

View File

@@ -0,0 +1,77 @@
import { chromium } from 'playwright';
import { join } from 'path';
const BASE = 'http://localhost:8901';
const DOCS = join(import.meta.dirname, 'docs');
const VP = { width: 1280, height: 800 };
async function screenshot(page, name) {
await page.waitForTimeout(500);
await page.screenshot({ path: join(DOCS, name), fullPage: false });
console.log(`${name}`);
}
const browser = await chromium.launch();
// --- DARK THEME ---
let ctx = await browser.newContext({ viewport: VP, colorScheme: 'dark' });
let page = await ctx.newPage();
// Dashboard (dark)
await page.goto(BASE);
await page.waitForLoadState('networkidle');
await screenshot(page, 'screenshot-dashboard-dark.png');
// Viewer - open MEMORY.md (dark)
// Click on MEMORY.md in file tree
await page.click('text=MEMORY.md');
await page.waitForTimeout(800);
await screenshot(page, 'screenshot-viewer-dark.png');
// Editor mode (dark) - click edit button
const editBtn = page.locator('button:has-text("Edit"), button[title*="edit"], button[title*="Edit"], [aria-label*="edit"], [aria-label*="Edit"]').first();
if (await editBtn.count() > 0) {
await editBtn.click();
await page.waitForTimeout(500);
await screenshot(page, 'screenshot-editor-dark.png');
}
// Search panel (dark) - Ctrl+K
await page.keyboard.press('Control+k');
await page.waitForTimeout(500);
// Type something to show results
await page.keyboard.type('memory');
await page.waitForTimeout(800);
await screenshot(page, 'screenshot-search-dark.png');
await page.keyboard.press('Escape');
await ctx.close();
// --- LIGHT THEME ---
ctx = await browser.newContext({ viewport: VP, colorScheme: 'light' });
page = await ctx.newPage();
// Set light theme via localStorage before navigating
await page.addInitScript(() => {
localStorage.setItem('theme', 'light');
});
await page.goto(BASE);
await page.waitForLoadState('networkidle');
// Also try setting it after load
await page.evaluate(() => {
localStorage.setItem('theme', 'light');
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
});
await page.reload();
await page.waitForLoadState('networkidle');
await screenshot(page, 'screenshot-dashboard-light.png');
// Viewer light
await page.click('text=MEMORY.md');
await page.waitForTimeout(800);
await screenshot(page, 'screenshot-viewer-light.png');
await ctx.close();
await browser.close();
console.log('🎉 All screenshots done!');

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"noEmitOnError": false,
"outDir": "./server-dist",
"rootDir": "./server",
"declaration": false,
"sourceMap": false,
"resolveJsonModule": true
},
"include": ["server/**/*"],
"exclude": ["node_modules", "dist", "server-dist"]
}

View File

@@ -0,0 +1,37 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: false,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/**',
'dist/**',
'**/*.d.ts',
'**/*.config.*',
],
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3001',
ws: true,
},
}
}
});