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
16
memory-viewer/.dockerignore
Normal 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
|
||||
28
memory-viewer/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
18
memory-viewer/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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.
|
||||
123
memory-viewer/.github/workflows/docker-build.yml
vendored
Normal 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
@@ -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?
|
||||
46
memory-viewer/CHANGELOG.md
Normal 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.
|
||||
52
memory-viewer/CONTRIBUTING.md
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,156 @@
|
||||
# 📝 Memory Viewer for OpenClaw
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://nodejs.org/)
|
||||
[](https://github.com/silicondawn/memory-viewer/releases/tag/v1.2.0)
|
||||
[](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
|
||||
92
memory-viewer/README.zh-CN.md
Normal file
@@ -0,0 +1,92 @@
|
||||
**中文** | [English](./README.md)
|
||||
|
||||
# 📝 Memory Viewer for OpenClaw
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://nodejs.org/)
|
||||
[](https://github.com/silicondawn/memory-viewer/releases/tag/v1.2.0)
|
||||
[](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 渲染** — 支持 GFM(GitHub 风格 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
|
||||
34
memory-viewer/docker-compose.yml
Normal 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
|
||||
BIN
memory-viewer/docs/screenshot-dashboard-dark.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
memory-viewer/docs/screenshot-dashboard-light.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
memory-viewer/docs/screenshot-editor-dark.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
memory-viewer/docs/screenshot-search-dark.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
memory-viewer/docs/screenshot-viewer-dark.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
memory-viewer/docs/screenshot-viewer-light.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
26
memory-viewer/index.html
Normal 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
83
memory-viewer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
memory-viewer/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
memory-viewer/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
21
memory-viewer/public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
60
memory-viewer/public/sw.js
Normal 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))
|
||||
);
|
||||
});
|
||||
59
memory-viewer/scripts/build-docker.sh
Executable 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"
|
||||
41
memory-viewer/server-dist/api.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
1134
memory-viewer/server-dist/index.js
Normal file
50
memory-viewer/server/api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
99
memory-viewer/server/cron-trigger.mjs
Normal 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);
|
||||
});
|
||||
1466
memory-viewer/server/index.ts
Normal file
624
memory-viewer/src/App.tsx
Normal 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
@@ -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();
|
||||
}
|
||||
42
memory-viewer/src/components/AgentStatus.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
236
memory-viewer/src/components/AgentStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
memory-viewer/src/components/BootstrapWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
memory-viewer/src/components/Changelog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
275
memory-viewer/src/components/Connections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
memory-viewer/src/components/ContributionHeatmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
486
memory-viewer/src/components/CronManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
memory-viewer/src/components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
memory-viewer/src/components/FileTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
708
memory-viewer/src/components/FileViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
memory-viewer/src/components/MarkdownEditor.tsx
Normal 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%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
245
memory-viewer/src/components/SearchPanel.tsx
Normal 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")} “{query}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
77
memory-viewer/src/components/SensitiveMask.tsx
Normal 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}</>;
|
||||
}
|
||||
305
memory-viewer/src/components/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
memory-viewer/src/components/SkillsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
311
memory-viewer/src/components/Tags.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
memory-viewer/src/components/Timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
memory-viewer/src/hooks/useAgents.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
120
memory-viewer/src/hooks/useConnections.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
memory-viewer/src/hooks/useLocale.ts
Normal 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);
|
||||
}
|
||||
75
memory-viewer/src/hooks/useResizableSidebar.ts
Normal 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 };
|
||||
}
|
||||
23
memory-viewer/src/hooks/useSensitive.ts
Normal 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);
|
||||
28
memory-viewer/src/hooks/useTheme.ts
Normal 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 };
|
||||
}
|
||||
44
memory-viewer/src/hooks/useWebSocket.ts
Normal 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]);
|
||||
}
|
||||
18
memory-viewer/src/hooks/useZoom.ts
Normal 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
@@ -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
@@ -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;
|
||||
}
|
||||
18
memory-viewer/src/main.tsx
Normal 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>,
|
||||
);
|
||||
25
memory-viewer/src/plugins/PluginSlot.tsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
memory-viewer/src/plugins/registry.ts
Normal 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();
|
||||
20
memory-viewer/src/plugins/types.ts
Normal 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;
|
||||
}
|
||||
8
memory-viewer/src/test/setup.ts
Normal 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();
|
||||
});
|
||||
84
memory-viewer/src/themes/apply.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
6
memory-viewer/src/themes/builtin/default.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { MarkdownTheme } from "../types";
|
||||
|
||||
export const defaultTheme: MarkdownTheme = {
|
||||
id: "default",
|
||||
name: "Default",
|
||||
};
|
||||
52
memory-viewer/src/themes/builtin/medium.ts
Normal 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;',
|
||||
},
|
||||
};
|
||||
9
memory-viewer/src/themes/index.ts
Normal 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";
|
||||
67
memory-viewer/src/themes/registry.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
33
memory-viewer/src/themes/types.ts
Normal 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;
|
||||
}
|
||||
77
memory-viewer/take-screenshots.mjs
Normal 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!');
|
||||
28
memory-viewer/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
memory-viewer/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
memory-viewer/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
19
memory-viewer/tsconfig.server.json
Normal 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"]
|
||||
}
|
||||
37
memory-viewer/vite.config.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||