diff --git a/app/flowix-web/features/document/components/document-container.tsx b/app/flowix-web/features/document/components/document-container.tsx index 2fa87f21..f061f72a 100644 --- a/app/flowix-web/features/document/components/document-container.tsx +++ b/app/flowix-web/features/document/components/document-container.tsx @@ -27,10 +27,15 @@ import { useDocumentFinalize } from '@features/document/components/session/use-d import { useExternalDocumentChangeWatch } from '@features/document/components/session/use-external-document-change-watch'; import { useExternalDocumentImport } from '@features/document/components/session/use-external-document-import'; import { LazyDocumentEditor } from '@features/document/components/lazy-document-editor'; +import { LazyMarkmapView } from '@features/document/components/lazy-markmap-view'; import { NotePropertiesDialog } from '@features/document/components/note-properties-dialog'; import type { MarkdownEditorHandle } from '@features/editor/markdown-editor'; import backgroundImage from '@/assets/bg.document.png'; import { useI18n } from '@features/i18n'; +import { FileText, GitFork } from 'lucide-react'; +import { Tooltip } from '@shared/ui/tooltip'; + +type DocumentViewMode = 'editor' | 'markmap'; export function DocumentContainer({ filePath, @@ -130,6 +135,20 @@ export function DocumentContainer({ }); const [propertiesOpen, setPropertiesOpen] = useState(false); const [propertiesContentSnapshot, setPropertiesContentSnapshot] = useState(null); + const [viewMode, setViewMode] = useState('editor'); + + const changeViewMode = useCallback((nextMode: DocumentViewMode) => { + if (nextMode === viewMode) return; + if (nextMode === 'markmap') { + const latestContent = flushPendingEditorChanges(); + if (latestContent !== null) { + handleChange(latestContent); + setState((prev) => ({ ...prev, fullContent: latestContent })); + } + finalizeMemoRename(); + } + setViewMode(nextMode); + }, [finalizeMemoRename, flushPendingEditorChanges, handleChange, setState, viewMode]); // Publish the external-import api upward so the titlebar (rendered as a // sibling above the content area) can show the file path and the save @@ -356,8 +375,48 @@ export function DocumentContainer({ return (
+ {state.fullContent && ( +
+ + + + + + +
+ )}
- {state.fullContent && ( + {state.fullContent && viewMode === 'editor' && ( )} + {state.fullContent && viewMode === 'markmap' && ( + + )}
{!isExternalDocument && memoId && ( + import('./markmap/markmap-view').then((module) => ({ default: module.MarkmapView })), +); + +export function LazyMarkmapView({ content }: { content: string }) { + const { t } = useI18n(); + return ( + + {t('document.markmap.loading')} +
+ )} + > + + + ); +} diff --git a/app/flowix-web/features/document/components/markmap/markmap-data.test.ts b/app/flowix-web/features/document/components/markmap/markmap-data.test.ts new file mode 100644 index 00000000..0ab5267b --- /dev/null +++ b/app/flowix-web/features/document/components/markmap/markmap-data.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { buildMarkmapDocument, hasMarkmapContent, type MarkmapDocument, type MarkmapRoot } from './markmap-data'; + +function blockFor(document: MarkmapDocument, node: MarkmapRoot) { + return document.blocks[node.payload!.blockId]; +} + +describe('buildMarkmapDocument', () => { + it('keeps frontmatter, headings, text, and nested lists in one document tree', () => { + const document = buildMarkmapDocument(`--- +title: Demo +status: active +--- +# Product + +Product overview text. + +## Research +- Interviews + - Customers +- Competitors + +## Delivery +- Desktop app +`); + + expect(blockFor(document, document.root).title).toBe('Product'); + expect(document.root.children.map((node) => blockFor(document, node).kind)).toEqual([ + 'frontmatter', + 'paragraph', + 'heading', + 'heading', + ]); + const research = document.root.children.find((node) => blockFor(document, node).title === 'Research')!; + expect(research.children.map((node) => blockFor(document, node).title)).toEqual([ + 'Interviews', + 'Competitors', + ]); + expect(research.children[0].children.map((node) => blockFor(document, node).title)).toEqual([ + 'Customers', + ]); + expect(hasMarkmapContent(document)).toBe(true); + }); + + it('supports prose-only documents instead of treating them as empty', () => { + const document = buildMarkmapDocument('A paragraph without headings or lists.'); + const paragraphs = Object.values(document.blocks).filter((block) => block.kind === 'paragraph'); + + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0].markdown).toBe('A paragraph without headings or lists.'); + expect(hasMarkmapContent(document)).toBe(true); + }); + + it('keeps a truly empty document in the empty state', () => { + expect(hasMarkmapContent(buildMarkmapDocument(''))).toBe(false); + }); + + it('recognizes Mermaid diagrams and AI thread cards as rich blocks', () => { + const document = buildMarkmapDocument(`# Architecture + +\`\`\`mermaid +flowchart TD + User --> Flowix +\`\`\` + +::agent-thread-card{instanceId="agent-inst-1" threadId="thread-1" title="Review architecture" agentType="codex" agentRoleMemoId="" agentRoleName="Architect" collapsed="false" inputDraft="Follow%20up"} +`); + const blocks = Object.values(document.blocks); + const diagram = blocks.find((block) => block.kind === 'mermaid'); + const agent = blocks.find((block) => block.kind === 'agent'); + + expect(diagram).toMatchObject({ language: 'mermaid', title: 'Flowchart' }); + expect(diagram?.markdown).toContain('User --> Flowix'); + expect(agent?.agent).toEqual({ + instanceId: 'agent-inst-1', + threadId: 'thread-1', + title: 'Review architecture', + agentType: 'codex', + agentRoleName: 'Architect', + inputDraft: 'Follow up', + }); + }); + + it('escapes raw HTML in map node labels', () => { + const document = buildMarkmapDocument('# Safe\n\n'); + const paragraphNode = document.root.children[0]; + + expect(paragraphNode.content).toContain('<img'); + expect(paragraphNode.content).not.toContain('; +} + +interface HeadingFrame { + depth: number; + node: MarkmapRoot; +} + +const AGENT_CARD_RE = /^::agent-thread-card\{([^}]*)\}[ \t]*$/; +const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeRawHtml(markdown: string): string { + return markdown.replace(//g, '>'); +} + +function renderInline(markdown: string): string { + const singleLine = escapeRawHtml(markdown).replace(/\s*\n\s*/g, ' ').trim(); + if (!singleLine) return ''; + const transformed = inlineTransformer.transform(`- ${singleLine}`).root; + return transformed.content || escapeHtml(singleLine); +} + +function plainText(markdown: string): string { + return markdown + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/[`*_~>#]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function summarize(markdown: string, limit = 220): string { + const value = plainText(markdown); + return value.length > limit ? `${value.slice(0, limit).trimEnd()}…` : value; +} + +function nodeBadge(kind: MarkmapBlockKind, label: string): string { + return `${escapeHtml(label)}`; +} + +function nodeContent(kind: MarkmapBlockKind, label: string, markdown: string): string { + const summary = summarize(markdown); + const content = renderInline(summary || label); + const accessibleText = escapeHtml(`${label}: ${summary || label}`); + return `${nodeBadge(kind, label)}${content}`; +} + +function headingContent(markdown: string): string { + const text = plainText(markdown); + return `${renderInline(markdown) || escapeHtml(text)}`; +} + +function unescapeAgentAttr(value: string): string { + return value.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); +} + +function parseAgentAttrs(rawAttrs: string): Record { + const attrs: Record = {}; + const attrRe = /(\w+)="((?:\\"|\\\\|[^"])*)"/g; + let match: RegExpExecArray | null; + while ((match = attrRe.exec(rawAttrs))) { + attrs[match[1]] = unescapeAgentAttr(match[2]); + } + return attrs; +} + +function decodeInputDraft(value: string | undefined): string | null { + if (!value) return null; + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseAgentBlock(markdown: string): MarkmapAgentBlock | null { + const match = AGENT_CARD_RE.exec(markdown.trim()); + if (!match) return null; + const attrs = parseAgentAttrs(match[1]); + return { + instanceId: attrs.instanceId || null, + threadId: attrs.threadId || null, + title: attrs.title || '', + agentType: attrs.agentType || 'flowix', + agentRoleName: attrs.agentRoleName || null, + inputDraft: decodeInputDraft(attrs.inputDraft), + }; +} + +function mermaidTitle(source: string): string { + const firstLine = source.trim().split(/\r?\n/, 1)[0]?.toLowerCase() ?? ''; + if (firstLine.startsWith('sequencediagram')) return 'Sequence'; + if (firstLine.startsWith('statediagram')) return 'State'; + if (firstLine.startsWith('classdiagram')) return 'Class'; + if (firstLine.startsWith('erdiagram')) return 'ER'; + if (firstLine.startsWith('gantt')) return 'Gantt'; + if (firstLine.startsWith('pie')) return 'Pie'; + if (firstLine.startsWith('mindmap')) return 'Mindmap'; + return 'Flowchart'; +} + +function mermaidSummary(source: string): string { + const lines = source + .trim() + .split(/\r?\n/) + .slice(1) + .map((line) => line.trim()) + .filter((line) => line && !/^(classDef|class|style|linkStyle|click)\b/i.test(line)) + .slice(0, 4) + .map((line) => line + .replace(/\b[A-Za-z_]\w*\[([^\]]+)]/g, '$1') + .replace(/\b[A-Za-z_]\w*\(([^)]+)\)/g, '$1') + .replace(/\b[A-Za-z_]\w*\{([^}]+)}/g, '$1') + .replace(/[-=.]+(?:\|[^|]*\|)?\s*>/g, ' → ') + .replace(/\s+/g, ' ')); + return lines.join(' · ') || mermaidTitle(source); +} + +function tokenText(token: Token): string { + if ('text' in token && typeof token.text === 'string') return token.text; + return token.raw?.trim() ?? ''; +} + +function listItemOwnMarkdown(item: Tokens.ListItem): string { + const ownTokens = item.tokens.filter((token) => token.type !== 'list'); + const text = ownTokens.map(tokenText).filter(Boolean).join('\n').trim(); + return text || item.text.trim(); +} + +function isListToken(token: Token): token is Tokens.List { + return token.type === 'list' && 'items' in token && Array.isArray(token.items); +} + +function isTableToken(token: Token): token is Tokens.Table { + return token.type === 'table' && 'header' in token && Array.isArray(token.header); +} + +export function buildMarkmapDocument( + markdown: string, + fallbackTitle = 'Document', +): MarkmapDocument { + let body = markdown; + let frontmatter: string | null = null; + const frontmatterMatch = FRONTMATTER_RE.exec(markdown); + if (frontmatterMatch) { + frontmatter = frontmatterMatch[1].trim(); + body = markdown.slice(frontmatterMatch[0].length); + } + + const tokens = marked.lexer(body, { gfm: true }); + const rootHeading = tokens.find( + (token): token is Tokens.Heading => token.type === 'heading' && token.depth === 1, + ) ?? tokens.find((token): token is Tokens.Heading => token.type === 'heading'); + + const blocks: Record = {}; + let blockSequence = 0; + const createNode = ( + kind: MarkmapBlockKind, + title: string, + source: string, + content: string, + extra?: Partial, + ): MarkmapRoot => { + const id = `markmap-block-${blockSequence++}`; + blocks[id] = { id, kind, title, markdown: source, ...extra }; + return { + content, + children: [], + payload: { blockId: id, kind }, + }; + }; + + const rootTitle = rootHeading?.text?.trim() || fallbackTitle; + const root = createNode( + 'heading', + rootTitle, + rootHeading?.raw?.trim() || rootTitle, + headingContent(rootTitle), + { synthetic: !rootHeading }, + ); + const headingStack: HeadingFrame[] = [{ depth: rootHeading?.depth ?? 0, node: root }]; + + const attach = (node: MarkmapRoot) => { + headingStack[headingStack.length - 1].node.children.push(node); + }; + + const addList = (list: Tokens.List, parent: MarkmapRoot) => { + for (const item of list.items) { + const ownMarkdown = listItemOwnMarkdown(item); + const label = item.task ? (item.checked ? 'Done' : 'Todo') : (list.ordered ? 'Step' : 'Item'); + const itemNode = createNode( + 'list', + summarize(ownMarkdown, 80) || label, + item.raw.trim(), + nodeContent('list', label, ownMarkdown), + ); + parent.children.push(itemNode); + for (const childToken of item.tokens) { + if (isListToken(childToken)) addList(childToken, itemNode); + } + } + }; + + if (frontmatter) { + attach(createNode( + 'frontmatter', + 'Properties', + frontmatter, + nodeContent('frontmatter', 'Properties', frontmatter), + )); + } + + for (const token of tokens) { + if (token === rootHeading || token.type === 'space') continue; + + if (token.type === 'heading') { + while ( + headingStack.length > 1 && + headingStack[headingStack.length - 1].depth >= token.depth + ) { + headingStack.pop(); + } + const headingNode = createNode( + 'heading', + token.text, + token.raw.trim(), + headingContent(token.text), + ); + headingStack[headingStack.length - 1].node.children.push(headingNode); + headingStack.push({ depth: token.depth, node: headingNode }); + continue; + } + + if (token.type === 'paragraph') { + const agent = parseAgentBlock(token.raw); + if (agent) { + const title = agent.title || agent.agentRoleName || 'AI conversation'; + attach(createNode( + 'agent', + title, + token.raw.trim(), + nodeContent('agent', agent.agentType, title), + { agent }, + )); + } else { + attach(createNode( + 'paragraph', + summarize(token.text, 80) || 'Text', + token.raw.trim(), + nodeContent('paragraph', 'Text', token.text), + )); + } + continue; + } + + if (isListToken(token)) { + addList(token, headingStack[headingStack.length - 1].node); + continue; + } + + if (token.type === 'code') { + const language = token.lang?.trim().split(/\s+/, 1)[0]?.toLowerCase() || null; + const isMermaid = language === 'mermaid'; + const kind: MarkmapBlockKind = isMermaid ? 'mermaid' : 'code'; + const label = isMermaid ? mermaidTitle(token.text) : (language || 'Code'); + attach(createNode( + kind, + label, + token.text, + nodeContent(kind, label, isMermaid ? mermaidSummary(token.text) : token.text), + { language }, + )); + continue; + } + + if (token.type === 'blockquote') { + attach(createNode( + 'blockquote', + summarize(token.text, 80) || 'Quote', + token.raw.trim(), + nodeContent('blockquote', 'Quote', token.text), + )); + continue; + } + + if (isTableToken(token)) { + const columns = token.header.map((cell) => plainText(cell.text)).filter(Boolean).join(' · '); + attach(createNode( + 'table', + columns || 'Table', + token.raw.trim(), + nodeContent('table', 'Table', columns), + )); + continue; + } + + if (token.type === 'hr') { + attach(createNode('separator', 'Separator', token.raw.trim(), nodeBadge('separator', 'Separator'))); + continue; + } + + if (token.type === 'html') { + const text = plainText(token.raw.replace(/<[^>]*>/g, ' ')); + attach(createNode( + 'html', + summarize(text, 80) || 'HTML', + token.raw.trim(), + nodeContent('html', 'HTML', text || token.raw), + )); + continue; + } + + const source = token.raw?.trim(); + if (source) { + attach(createNode( + 'paragraph', + summarize(source, 80) || 'Text', + source, + nodeContent('paragraph', 'Text', source), + )); + } + } + + return { root, blocks }; +} + +export function hasMarkmapContent(document: MarkmapDocument): boolean { + return Object.values(document.blocks).some((block) => !block.synthetic); +} diff --git a/app/flowix-web/features/document/components/markmap/markmap-inspector.tsx b/app/flowix-web/features/document/components/markmap/markmap-inspector.tsx new file mode 100644 index 00000000..8c5488a8 --- /dev/null +++ b/app/flowix-web/features/document/components/markmap/markmap-inspector.tsx @@ -0,0 +1,241 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Bot, Braces, FileText, LoaderCircle, Network, Quote, Table2, X } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { createAgentMessageViewModel, shouldRenderAgentMessage } from '@features/agent/message'; +import { useAgentConversationStore } from '@features/agent/store/agent-conversation-store'; +import { useI18n } from '@features/i18n'; +import { getAgentType, normalizeAgentTypeKey } from '@/lib/agent-types'; +import type { MarkmapBlock, MarkmapBlockKind } from './markmap-data'; + +interface MarkmapInspectorProps { + block: MarkmapBlock; + onClose: () => void; +} + +function MermaidPreview({ source }: { source: string }) { + const { t } = useI18n(); + const hostRef = useRef(null); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + setError(false); + if (hostRef.current) hostRef.current.replaceChildren(); + + void import('@features/editor/extensions/codeblock-shiki/mermaid-renderer') + .then(({ renderMermaidDiagram }) => renderMermaidDiagram(source)) + .then((svg) => { + if (cancelled || !hostRef.current) return; + hostRef.current.innerHTML = svg; + }) + .catch(() => { + if (!cancelled) setError(true); + }); + + return () => { + cancelled = true; + }; + }, [source]); + + if (error) { + return ( +
+ {t('document.markmap.diagramError')} +
{source}
+
+ ); + } + + return ( +
+
+
+
+ ); +} + +function AgentPreview({ block }: { block: MarkmapBlock }) { + const { language, t } = useI18n(); + const agentBlock = block.agent!; + const instance = useAgentConversationStore((state) => { + if (agentBlock.instanceId && state.instances[agentBlock.instanceId]) { + return state.instances[agentBlock.instanceId]; + } + if (!agentBlock.threadId) return null; + return Object.values(state.instances).find((item) => item.threadId === agentBlock.threadId) ?? null; + }); + const threadId = instance?.threadId || agentBlock.threadId; + const messageState = useAgentConversationStore((state) => ( + threadId ? state.messageStates[threadId] ?? null : null + )); + const loadMessages = useAgentConversationStore((state) => state.loadMessages); + const agentType = normalizeAgentTypeKey(instance?.agentType || agentBlock.agentType); + const agent = getAgentType(agentType); + const requestedThreadsRef = useRef(new Set()); + + useEffect(() => { + if (!threadId || requestedThreadsRef.current.has(threadId)) return; + if (messageState?.messages.length || messageState?.loadingInitial) return; + requestedThreadsRef.current.add(threadId); + void loadMessages(agentType, threadId); + }, [agentType, loadMessages, messageState, threadId]); + + const messages = useMemo(() => ( + (messageState?.messages ?? []) + .filter(shouldRenderAgentMessage) + .slice(-10) + .map((message) => createAgentMessageViewModel(message, language)) + ), [language, messageState?.messages]); + + return ( +
+
+ + + +
+
+ {instance?.title || agentBlock.title || agent.name} +
+
+ {agent.name} + {(instance?.role?.name || agentBlock.agentRoleName) && ( + <>·{instance?.role?.name || agentBlock.agentRoleName} + )} + {instance?.run?.status === 'running' && ( + + + )} +
+
+
+ +
+ {messageState?.loadingInitial && messages.length === 0 ? ( +
+
+ ) : messages.length > 0 ? messages.map((message) => ( +
+
+ {message.role === 'user' + ? t('document.markmap.agentUser') + : message.role === 'assistant' + ? agent.name + : message.role === 'reasoning' + ? message.reasoningLabel + : message.toolLabel || message.role} +
+ {message.role === 'tool' ? ( +
+ {message.toolSummary || message.visibleContent} +
+ ) : ( +
+ + {message.visibleContent} + +
+ )} +
+ )) : ( +
+
+ )} +
+ + {agentBlock.inputDraft && ( +
+ {t('document.markmap.agentDraft')} +
{agentBlock.inputDraft}
+
+ )} +
+ ); +} + +function blockLabel(kind: MarkmapBlockKind, t: ReturnType['t']): string { + const labels: Record = { + heading: t('document.markmap.blockHeading'), + paragraph: t('document.markmap.blockText'), + list: t('document.markmap.blockList'), + mermaid: t('document.markmap.blockDiagram'), + code: t('document.markmap.blockCode'), + agent: t('document.markmap.blockAgent'), + blockquote: t('document.markmap.blockQuote'), + table: t('document.markmap.blockTable'), + frontmatter: t('document.markmap.blockProperties'), + separator: t('document.markmap.blockSeparator'), + html: t('document.markmap.blockHtml'), + }; + return labels[kind]; +} + +function BlockIcon({ kind }: { kind: MarkmapBlockKind }) { + if (kind === 'mermaid') return