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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## MODIFIED Requirements

### Requirement: MindMapNode data model
### Requirement: MindMap data model
MindMapNode SHALL 包含以下字段:`id: string`(唯一标识)、`label: string`(节点标题)、`summary: string`(纯文本摘要)、`content?: string`(可选 Markdown 内容)、`contentType?: 'text' | 'markdown'`(可选内容类型,默认 `'text'`)、`children: MindMapNode[]`(子节点)、`sourceConversationIds: string[]`(来源对话 ID)、`sourceExcerpts: Record<string, string>`(来源摘录)、`editedByUser: boolean`(是否被用户编辑)。

#### Scenario: New node with markdown content
Expand Down
18 changes: 16 additions & 2 deletions openspec/specs/mindmap-data/spec.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
## ADDED Requirements
## Purpose

Define the core data model for mindmap nodes and mindmap collections, supporting tree structures, source provenance tracking, and IndexedDB persistence.

## Requirements

### Requirement: MindMap data model
系统 SHALL 使用以下数据模型表示思维导图:
Expand All @@ -11,6 +15,7 @@ MindMap {
maxDepth?: number // 最大生成深度:3-5 或 0(自动),默认 3
corpus: CorpusEntry[] // 语料库条目列表
monitoredConversationIds: string[] // 监听的对话 ID 列表
collapsedNodeIds?: string[] // 折叠节点 ID 列表
forceFullRebuild?: boolean // 强制全量重建模式
lastGeneratedAt?: number // 上次生成时间戳
createdAt: number // 创建时间戳
Expand All @@ -23,12 +28,13 @@ MindMapNode {
id: string // UUID
label: string // 节点显示文本
summary: string // 节点描述
content?: string // 可选 Markdown 内容
contentType?: 'text' | 'markdown' // 内容类型,默认 'text'
children: MindMapNode[] // 子节点列表
sourceConversationIds: string[] // 贡献该节点的 Conversation ID 列表
sourceExcerpts: Record<string, string> // Conversation ID → 消息文本摘录
editedByUser: boolean // 是否被用户手动编辑过(默认 false)
}
```

#### Scenario: MindMap data structure
- **WHEN** 系统创建新的思维导图
Expand All @@ -42,6 +48,14 @@ MindMapNode {
- **WHEN** 用户手动编辑节点
- **THEN** 节点 `editedByUser` 设为 true,`sourceConversationIds` 清空

#### Scenario: New node with markdown content
- **WHEN** 创建 MindMapNode 且指定 `contentType: 'markdown'` 和 `content: '## Title\n\nContent'`
- **THEN** 节点存储完整 Markdown 内容且类型标记为 markdown

#### Scenario: Existing node remains compatible
- **WHEN** 现有节点(无 `contentType` 和 `content` 字段)被反序列化
- **THEN** 节点正常加载,`contentType` 默认为 `'text'`,行为与旧版本一致

### Requirement: Create mindmap
系统 SHALL 允许用户创建新的思维导图。用户 MUST 提供图谱标题。创建后 SHALL 自动在侧边栏显示新图谱条目。

Expand Down
13 changes: 11 additions & 2 deletions openspec/specs/mindmap-generation/spec.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
## ADDED Requirements
## Purpose

Enable AI-powered mindmap generation from conversation history using LLM prompts, supporting both full rebuild and incremental update modes with structured JSON output.

## Requirements

### Requirement: Generate mindmap from conversation history
系统 SHALL 支持通过 LLM 从对话内容生成思维导图树结构。输入内容 SHALL 优先使用图谱语料库内容。生成模式 SHALL 根据图谱状态自动选择:首次生成使用全量模式(输出完整 Markdown/JSON 树),后续生成使用增量模式(输出操作指令)。增量模式 SHALL 提供旧树摘要而非完整树,减少 prompt token 消耗。
系统 SHALL 支持通过 LLM 从对话内容生成思维导图树结构。输入内容 SHALL 优先使用图谱语料库内容。生成模式 SHALL 根据图谱状态自动选择:首次生成使用全量模式(输出完整 Markdown/JSON 树),后续生成使用增量模式(输出操作指令)。增量模式 SHALL 提供旧树摘要而非完整树,减少 prompt token 消耗。生成 prompt SHALL 指示 LLM 在节点中使用 `content` 字段承载 Markdown 格式内容(加粗、斜体、行内代码、删除线、代码块、表格),并在输出 JSON 中标注 `contentType: 'markdown'`。系统 SHALL 在解析 JSON 响应时识别 `contentType` 和 `content` 字段并存储到 MindMapNode。

#### Scenario: First-time generation
- **WHEN** 用户对未包含树的图谱触发生成
Expand All @@ -11,6 +15,11 @@
- **WHEN** 图谱已有树结构,用户传入新内容触发生成
- **THEN** 系统使用增量模式,提供旧树摘要,LLM 输出操作指令

#### Scenario: AI generates node with markdown content
- **WHEN** 语料包含代码示例,且生成模式为全量或增量
- **THEN** LLM 输出的节点可能包含带 `contentType: 'markdown'` 的 `content` 字段(含代码块或表格)
- **AND** 系统正确解析并存储 `contentType` 和 `content` 字段

### Requirement: Markdown to tree parsing
系统 SHALL 将 LLM 返回的 Markdown 文本解析为 MindMapNode 树结构。解析规则:
- `# 标题` → 根节点(Tree 数组中添加一项)
Expand Down
8 changes: 6 additions & 2 deletions openspec/specs/mindmap-node-editing/spec.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
## MODIFIED Requirements
## Purpose

Allow users to edit mindmap node labels, summaries, and Markdown content through a modal dialog, with validation and user-edit tracking.

## Requirements

### Requirement: Node edit mode
系统 SHALL 允许用户双击节点进入编辑模式。编辑模式下 SHALL 弹出居中 Modal 弹窗(`MindMapEditModal`),包含 label 输入框和 summary 文本域。按 Enter 确认编辑并调用 `mindmapStore.updateNode`,按 Escape 或点击 Modal 外区域取消编辑。确认后节点 `editedByUser` 标记为 true。
系统 SHALL 允许用户双击节点进入编辑模式。编辑模式下 SHALL 弹出居中 Modal 弹窗(`MindMapEditModal`),包含 label 输入框、summary 文本域,以及当 `contentType` 为 `'markdown'` 时的 content Markdown 编辑器与预览切换按钮。按 Enter 确认编辑并调用 `mindmapStore.updateNode`,按 Escape 或点击 Modal 外区域取消编辑。确认后节点 `editedByUser` 标记为 true。

#### Scenario: Double-click to edit node
- **WHEN** 用户双击画布中的某个节点
Expand Down
105 changes: 100 additions & 5 deletions src/features/mindmap/MindMapEditModal.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Button } from '@/components/ui/button'
import type { MindMapNode } from '@/types/mindmap'

interface MindMapEditModalProps {
node: MindMapNode
onConfirm: (nodeId: string, label: string, summary: string) => void
onConfirm: (
nodeId: string,
label: string,
summary: string,
content?: string,
contentType?: 'text' | 'markdown',
) => void
onCancel: () => void
}

export default function MindMapEditModal({ node, onConfirm, onCancel }: MindMapEditModalProps) {
const [label, setLabel] = useState(node.label)
const [summary, setSummary] = useState(node.summary)
const [content, setContent] = useState(node.content ?? '')
const [contentType, setContentType] = useState<'text' | 'markdown'>(
node.contentType === 'markdown' ? 'markdown' : 'text',
)
const [previewing, setPreviewing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
Expand All @@ -21,14 +34,22 @@ export default function MindMapEditModal({ node, onConfirm, onCancel }: MindMapE

const handleConfirm = () => {
if (label.trim()) {
onConfirm(node.id, label.trim(), summary.trim())
onConfirm(
node.id,
label.trim(),
summary.trim(),
contentType === 'markdown' ? content.trim() : undefined,
contentType,
)
}
}

const isMarkdown = contentType === 'markdown'

return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-background/60 backdrop-blur-sm" onClick={onCancel} />
<div className="relative bg-popover border border-border rounded-lg shadow-xl p-5 w-[360px] space-y-4">
<div className="relative bg-popover border border-border rounded-lg shadow-xl p-5 w-[420px] max-h-[85vh] overflow-y-auto space-y-4">
<h3 className="text-sm font-semibold">编辑节点</h3>
<div className="space-y-3">
<div>
Expand Down Expand Up @@ -56,12 +77,86 @@ export default function MindMapEditModal({ node, onConfirm, onCancel }: MindMapE
value={summary}
onChange={(e) => setSummary(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') onCancel()
if (e.key === 'Escape' && !e.shiftKey) onCancel()
}}
placeholder="摘要内容"
rows={3}
rows={2}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-muted-foreground block">
内容类型
</label>
<div className="flex gap-1">
<Button
variant={contentType === 'text' ? 'default' : 'outline'}
size="sm"
className="h-6 text-[11px] px-2"
onClick={() => setContentType('text')}
>
纯文本
</Button>
<Button
variant={contentType === 'markdown' ? 'default' : 'outline'}
size="sm"
className="h-6 text-[11px] px-2"
onClick={() => setContentType('markdown')}
>
Markdown
</Button>
</div>
</div>
{isMarkdown && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] text-muted-foreground">
Markdown 内容(可选)
</span>
<Button
variant="ghost"
size="sm"
className="h-6 text-[11px] px-2"
onClick={() => setPreviewing(!previewing)}
>
{previewing ? '编辑' : '预览'}
</Button>
</div>
{previewing ? (
<div className="w-full min-h-[60px] max-h-[200px] overflow-y-auto border border-input rounded px-3 py-2 text-xs prose prose-sm dark:prose-invert max-w-none prose-code:text-[11px] prose-pre:bg-muted prose-pre:text-[11px] prose-table:text-[11px]">
<Markdown remarkPlugins={[remarkGfm]}>
{content || '_无内容_'}
</Markdown>
</div>
) : (
<textarea
className="w-full text-sm bg-background border border-input rounded px-3 py-2 outline-none focus:ring-2 focus:ring-primary/30 resize-none font-mono text-xs"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && !e.shiftKey) {
if (previewing) {
setPreviewing(false)
} else {
onCancel()
}
}
}}
placeholder={`**bold** *italic* \`code\` ~~strike~~

\`\`\`python
print("code block")
\`\`\`

| A | B |
|---|---|
| 1 | 2 |`}
rows={6}
/>
)}
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={onCancel}>
Expand Down
39 changes: 35 additions & 4 deletions src/features/mindmap/MindMapNodeComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
import { memo } from 'react'
import { memo, useRef, useEffect } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { ChevronDown, ChevronRight, Pencil } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { MindMapNodeData } from './types'

function MindMapNodeComponent({ id, data, selected }: NodeProps & { data: MindMapNodeData }) {
const isMarkdown = data.contentType === 'markdown' && data.content
const contentRef = useRef<HTMLDivElement>(null)

useEffect(() => {
const el = contentRef.current
if (!el || !isMarkdown) return

const handleWheel = (e: WheelEvent) => {
const { scrollTop, scrollHeight, clientHeight } = el
const atTop = scrollTop <= 0
const atBottom = scrollTop + clientHeight >= scrollHeight - 1

if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
e.stopPropagation()
}
}

el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel)
}, [isMarkdown])

return (
<div
className={`mindmap-node group relative rounded-lg border bg-card px-3 py-2 shadow-sm transition-colors min-w-[180px] max-w-[280px]
className={`mindmap-node group relative rounded-lg border bg-card px-3 py-2 shadow-sm transition-colors min-w-[180px] max-w-[320px]
${isMarkdown ? 'min-h-[80px]' : ''}
${selected ? 'border-primary ring-1 ring-primary/30' : 'border-border hover:border-primary/40'}`}
>
<Handle type="target" position={Position.Left} className="!bg-muted-foreground" />
Expand Down Expand Up @@ -46,11 +70,18 @@ function MindMapNodeComponent({ id, data, selected }: NodeProps & { data: MindMa
</span>
</div>

{data.summary && (
{isMarkdown ? (
<div
ref={contentRef}
className="mt-1.5 text-xs leading-relaxed max-h-[180px] overflow-y-auto prose prose-sm dark:prose-invert max-w-none prose-code:text-[11px] prose-pre:bg-muted prose-pre:text-[11px] prose-table:text-[11px] prose-th:px-2 prose-td:px-2"
>
<Markdown remarkPlugins={[remarkGfm]}>{data.content!}</Markdown>
</div>
) : data.summary ? (
<div className="text-[11px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">
{data.summary}
</div>
)}
) : null}
</div>
)
}
Expand Down
10 changes: 8 additions & 2 deletions src/features/mindmap/MindMapTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,14 @@ export default function MindMapTree({
}, [])

const handleEditConfirm = useCallback(
(nodeId: string, label: string, summary: string) => {
if (mindmapId) updateNode(mindmapId, nodeId, { label, summary })
(
nodeId: string,
label: string,
summary: string,
content?: string,
contentType?: 'text' | 'markdown',
) => {
if (mindmapId) updateNode(mindmapId, nodeId, { label, summary, content, contentType })
setEditNode(null)
},
[mindmapId, updateNode],
Expand Down
2 changes: 2 additions & 0 deletions src/features/mindmap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { type Node, type Edge } from '@xyflow/react'
export interface MindMapNodeData extends Record<string, unknown> {
label: string
summary: string
content?: string
contentType?: 'text' | 'markdown'
editedByUser: boolean
sourceCount: number
hasChildren: boolean
Expand Down
Loading
Loading