Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Progressive Mindmap — Agent Guide

## Commands (run in order)

```bash
npm run dev # Vite dev server → http://localhost:5173
npm run build # tsc -b && vite build (typecheck then build)
npm test # vitest run (happy-dom, no jsdom)
npm run lint # eslint . (TypeScript + react-hooks + react-refresh)
npm run format # prettier --write . (no semi, singleQuote, trailingComma: all)
```

CI pipeline (`.github/workflows/ci.yml`): `npm ci → npx tsc --noEmit → npx eslint . → npm test`

## Architecture

- Fully client-side SPA (no backend). All data in **IndexedDB** via `idb` library.
- **Zustand** stores persisted through IndexedDB adapter (`src/lib/indexeddb-storage-adapter.ts`). Stores: `providerStore`, `conversationStore`, `mindmapStore`, `chatStore`.
- DB version 5 (`src/lib/db.ts`). Object stores: `providers`, `conversations`, `messages`, `mindmaps`, `zustand-persist`.
- 4 feature modules: `chat/`, `mindmap/`, `conversation/`, `provider/`. Pure logic in `lib/`. Types in `types/`.
- Path alias `@/` → `./src/` (configured in both `vite.config.ts` and `vitest.config.ts`).
- UI: React 18 + Tailwind CSS v4 (`@tailwindcss/vite` plugin) + shadcn/ui (base-nova style) + `@xyflow/react` + dagre layout.

## Mindmap Generation

- `src/lib/mindmap-generator.ts` handles all generation logic.
- Two modes: **full** (rebuild entire tree) and **incremental** (surgical operations).
- Incremental operations: `add_child`, `update`, `merge`, `delete_leaf`, `noop`.
- `editedByUser: true` nodes are **never** overwritten by AI (protected in `applyOperations`).
- Source tracking via `[源: convId/msgId]` annotations in prompts.
- `maxDepth=0` means "auto depth" (no hard limit). Default is 3.
- All generation prompts are in **Chinese**.

## IndexedDB Persistence

- Zustand stores use `createIndexedDBStorage()` (NOT `localStorage`).
- If IndexedDB is unavailable, falls back to memory-only with a console warning.
- `providerStore` rehydrate hook pre-seeds OpenRouter as default provider on first load.

## TypeScript & Linting Rules

- `strict: true`, `noUncheckedIndexedAccess: true` — all array/object access must be guarded.
- `noUnusedLocals: true`, `noUnusedParameters: true` — no dead code.
- `noUnusedLocals` is a **compile error**, not just lint warning.
- `noUncheckedIndexedAccess` means array access `arr[i]` returns `T | undefined` — always guard.
- ESLint: `@typescript-eslint/no-unused-vars: error` with `argsIgnorePattern: ^_`.
- `react-refresh/only-export-components: warn` (off for `src/components/ui/`).
- No `as any`, no `// @ts-ignore` — project convention (per CONTRIBUTING.md).

## Testing

- Vitest with `happy-dom` environment (`vitest.config.ts`).
- Tests in `__tests__/` dirs next to source: `src/lib/__tests__/`, `src/stores/__tests__/`, `src/features/*/__tests__/`.
- `test-setup.ts` imports `@testing-library/jest-dom/vitest`.
- No jsdom — happy-dom only. Some DOM APIs may differ.

## Prettier

```json
{ "semi": false, "singleQuote": true, "tabWidth": 2, "trailingComma": "all", "printWidth": 100 }
```

## OpenCode Workflow

- Work on `opencode` branch only. Never commit to `main` (`.opencode/rules.md`).
- Never push to remote without asking.
- OpenSpec workflow in `openspec/` dir.
- Plugins in `.opencode/plugins/`, skills in `.opencode/skills/`.
4 changes: 4 additions & 0 deletions opencode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"lsp": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-06
101 changes: 101 additions & 0 deletions openspec/changes/archive/2026-05-07-dual-stream-mindmap/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
## Context

Current mindmap generation is a two-phase batch process:

```
User asks -> AI streams answer -> answer complete -> 5s debounce
-> separate LLM call (stream: false, full/incremental)
-> parse JSON -> update mindmap tree
```

Problems:
- **Latency**: 10-30s extra wait after chat completes
- **Quality**: One LLM call must read full conversation + extract concepts + build hierarchy + output valid JSON - too many tasks
- **UX gap**: No real-time mindmap growth during conversation
- **Token waste**: Separate generation call re-reads conversation history already seen by chat model
- **Node ID fragility**: Incremental mode requires LLM to output exact node IDs - any mismatch fails silently

The proposal calls for a dual-stream approach where every chat response simultaneously produces both a natural language answer and structured knowledge, with mindmap-as-context replacing raw conversation history for long-term memory.

## Goals / Non-Goals

**Goals:**
- Chat responses carry both Markdown answer and structured knowledge JSON in a single streaming call
- Mindmap updates in real-time as each chat response arrives (no separate generation step)
- Mindmap tree replaces raw conversation history as LLM long-term context (hybrid: tree + last 1-2 raw rounds)
- Merge algorithm handles dedup and path matching (eliminate fragile node ID dependency)
- Graceful fallback when LLM does not output knowledge blocks
- Backward compatible: existing corpus + manual full rebuild still works

**Non-Goals:**
- Realtime streaming of individual knowledge nodes during LLM response (knowledge is extracted only after the complete response)
- Cross-mindmap knowledge linking
- Multi-modal knowledge extraction (images, audio)
- Plugin/extensibility system for custom extractors

## Decisions

### D1: Knowledge block via post-pended delimiter (not interleaved)

**Approach**: The LLM outputs normal Markdown answer text, then appends `<!--KNWL-->...<!--/KNWL-->` at the end.

| Option | Pro | Con |
|--------|-----|-----|
| Post-pended delimiter | Simple, answer streams normally | Knowledge only arrives at end of response |
| Interleaved JSON | Knowledge arrives mid-stream | Complex streaming JSON parser needed |
| Function calling | Structured, reliable | Requires function-calling API, not universal |
| Separate API call | Full streaming, no prompt change | 2x API cost, extra latency |

**Decision**: Post-pended delimiter. The simpler approach wins for v1. Knowledge arriving at end is acceptable since typical response time is 5-15s.

### D2: Algorithmic merge (not LLM-based)

Knowledge blocks from each response are independent. Merging them into the existing mindmap tree is an algorithmic task:
- Match existing tree nodes by `category` path + fuzzy label comparison (edit distance < 0.3)
- Same path + same label -> update node (unless editedByUser)
- Same path + different label -> add as sibling
- Different path + same/similar concept -> treat as independent branch

**Why not LLM**: Tree merge is a deterministic tree operation. Using an LLM would add latency, cost, and potential inconsistency.

### D3: Mindmap-as-context with hybrid strategy

LLM context = mindmap tree serialized as Markdown + last 1-2 raw Q&A rounds.

Rule:
- If mindmap.tree is empty -> pass full raw conversation history (legacy behavior)
- If mindmap.tree is non-empty -> pass tree + last 2 messages

The mindmap tree is serialized as flat Markdown headings (same format as treeToMarkdown()). Token cost: ~8 tokens per node (vs ~50 tokens per raw message).

### D4: Knowledge data model

```typescript
interface KnowledgeNode {
label: string // concept name
category: string[] // hierarchical path, e.g. ["React", "Hooks"]
summary: string // one-line description
content?: string // optional Markdown content
contentType?: text | markdown
}
```

The `category` path replaces fragile deterministic node IDs as the primary location mechanism. This is more robust because:
- LLM can express paths naturally using concept names
- Fuzzy matching allows for minor wording variations
- New paths create new branches, no ID collision

### D5: Fallback strategy

After each chat response, if no `<!--KNWL-->` block is detected in the stream, schedule the existing batch generation flow with a reduced 2s debounce (down from 5s). This ensures backward compatibility with models that do not follow the knowledge extraction instruction.

## Risks / Trade-offs

| Risk | Impact | Mitigation |
|------|--------|------------|
| LLM does not output knowledge block | No mindmap updates | Fallback to batch generation |
| Knowledge JSON malformed | Parse error, node lost | Try partial parse; fallback to batch for this response only |
| Category path inconsistent across responses | Duplicate branches | Merge algorithm with fuzzy path matching |
| Knowledge extraction distracts from answer quality | Poorer chat responses | A/B test with/without extraction prompt; keep extraction instructions minimal |
| Mindmap serialization in context consumes prompt tokens | Higher per-request cost | Token monitoring; cap at 200 nodes serialized |
| User expects single batch-style generation (all at once) | Confusion with incremental growth | Toast on first dual-stream response explaining real-time updates |
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Why

Current mindmap generation is a two-phase batch process: chat completes -> user triggers -> separate LLM call generates mindmap. Dual-stream output eliminates the separate generation step: every chat response carries a full updated mindmap in the same API call, with zero extra latency. Using JSON mode (response_format: "json_object") ensures the mindmap JSON is always valid. The accumulated mindmap tree replaces raw conversation history as LLM context, reducing prompt tokens by 60-80%.

## What Changes

- **Single-call mindmap output**: Chat response includes both answer text and full updated mindmap JSON in one API call
- **JSON mode**: Providers supporting response_format: "json_object" output {"answer": "...", "mindmap": {"nodes": [...]}} - guaranteed valid JSON
- **Fallback marker mode**: For providers without JSON mode, <!--MINDMAP--> delimiters separate answer from mindmap
- **Mindmap-as-context**: The existing mindmap tree (not raw history) is fed to the LLM as context, with last recent messages retained
- **Simplified data model**: Corpus, batch generation, knowledge-applier, and all intermediate types removed
- **Edited node preservation**: findEditedNodes/mergeEditedNodes preserve user-edited nodes across regenerations
- Auto-triggered generation (5s debounce) and corpus curation are **removed**

## Capabilities

### New Capabilities

- `full-mindmap-output`: Single API call produces both chat answer and complete updated mindmap tree
- `mindmap-as-context`: Using accumulated mindmap tree as LLM context instead of raw conversation history

### Modified Capabilities

- `chat-interface`: Response is now non-streaming single call; JSON mode with answer/mindmap fields; fallback marker parsing
- `mindmap-generation`: Full tree output replaces batch generation; editedByUser nodes preserved via findEditedNodes/mergeEditedNodes
- `mindmap-corpus`: **REMOVED** - corpus and all related UI/data are deleted
- `conversation-management`: Hybrid context construction (mindmap tree + last messages) replaces full history

## Impact

- src/features/chat/ChatPage.tsx: JSON mode + marker mode response processing; mindmap-as-context; association dialog
- src/lib/llm-client.ts: chat() with useJsonMode parameter
- src/lib/mindmap-generator.ts: buildFullMindmapPrompt() dual-mode; parseJsonToTree with comma repair; mindmapTreeToContext; findEditedNodes/mergeEditedNodes
- src/features/mindmap/MindMapPanel.tsx: Simplified - remove corpus/generate/settings; add linked conversation list
- src/stores/mindmapStore.ts: Remove corpus actions; simplify MindMap type
- src/types/mindmap.ts: Remove CorpusEntry, KnowledgeNode, IncrementalOperation, etc.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
## MODIFIED Requirements

### Requirement: Streaming response display
The system SHALL display LLM responses as streaming Markdown text in the chat bubble. The stream SHALL be processed through a dual-stream parser that:
- Detects `<!--KNWL-->` and `<!--/KNWL-->` delimiters
- Strips delimiters and their content from the displayed text
- Accumulates knowledge JSON between delimiters for mindmap application
- Renders only the non-knowledge portion as Markdown in the chat bubble

**Change from previous**: Stream format changes from pure Markdown to Markdown with optional knowledge block. The knowledge block is invisible to the user.

#### Scenario: Knowledge block stripped from display
- **WHEN** LLM streams `Some answer text.<!--KNWL-->[...]<!--/KNWL-->`
- **THEN** chat bubble displays only `Some answer text.` with no visible marker or JSON

#### Scenario: Knowledge block arrives before display content
- **WHEN** first stream chunk is `<!--KNWL-->[{"label":"X"}]\n<!--/KNWL-->\nAnswer text`
- **THEN** system buffers knowledge block, applies it, and displays only `Answer text`

### Requirement: Stop generation
The stop generation function SHALL also discard any partially-received knowledge block. If the knowledge block was partially buffered when generation stops, it SHALL be discarded and not applied to the mindmap.

**Change from previous**: Knowledge blocks add partial state that must be cleaned up on abort.

#### Scenario: Stop during knowledge block
- **WHEN** user stops generation mid-stream while `<!--KNWL-->[...` has been received but `<!--/KNWL-->` has not
- **THEN** partial knowledge buffer is discarded, mindmap is not updated with partial data

### Requirement: Auto-sync mode (REMOVED)
**Reason**: Replaced by inline knowledge extraction during chat streaming.
**Migration**: Mindmap updates happen automatically during chat response, no separate auto-sync trigger needed.

### Requirement: Monitored conversation auto-generation (REMOVED)
**Reason**: Replaced by inline knowledge extraction. Each monitored conversation response inherently updates the mindmap via its knowledge block.
**Migration**: No action needed. Mindmap updates are now implicit.

### Requirement: Message display and layout
**Change**: The max-width of the chat area SHALL remain unchanged. The knowledge block stripping does not affect layout.

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## MODIFIED Requirements

### Requirement: Chat history construction for LLM context
The system SHALL construct the LLM context using a hybrid strategy when `mindmap-as-context` mode is active:

- Serialized mindmap tree (as Markdown headings)
- Last 1-2 raw Q&A messages (for tone and wording nuance)
- Current user question

When `mindmap-as-context` is OFF (legacy mode or mindmap.tree is empty):
- Full raw conversation history is passed as before

**Change from previous**: Conversation history is no longer always passed in full. A hybrid context replaces it when mindmap is available.

#### Scenario: Hybrid context with existing mindmap
- **WHEN** conversation has 20 messages and mindmap has 50 nodes
- **THEN** LLM context = mindmap serialization + last 2 messages + current question

#### Scenario: Full history when mindmap is empty
- **WHEN** conversation has 20 messages and mindmap.tree is empty
- **THEN** LLM context = full 20 messages (legacy behavior)

Loading
Loading