diff --git a/backend/server.py b/backend/server.py index 7776b8b..25291cd 100644 --- a/backend/server.py +++ b/backend/server.py @@ -265,6 +265,28 @@ def create_app( content_type="text/plain", ) + async def get_help_docs(request: web.Request) -> web.Response: + public_dir = FRONTEND_DIR / "public" + if not public_dir.is_dir(): + return web.json_response([]) + files = sorted(p.name for p in public_dir.iterdir() if p.suffix.lower() == ".md" and p.is_file()) + result = [] + for fname in files: + text = (public_dir / fname).read_text(encoding="utf-8", errors="replace") + title = fname.rsplit(".", 1)[0].replace("-", " ").replace("_", " ").title() + result.append({"title": title, "content": text}) + return web.json_response(result) + + async def get_help_doc_file(request: web.Request) -> web.Response: + filename = request.match_info["filename"] + public_dir = FRONTEND_DIR / "public" + path = (public_dir / filename).resolve() + if not str(path).startswith(str(public_dir.resolve())) or not path.is_file(): + return web.Response(status=404, text="Not found") + text = path.read_text(encoding="utf-8", errors="replace") + title = filename.rsplit(".", 1)[0].replace("-", " ").replace("_", " ").title() + return web.json_response({"title": title, "content": text}) + async def get_nodes(request: web.Request) -> web.Response: return web.Response( text=_dumps(get_all_node_info()), @@ -545,6 +567,8 @@ def create_app( app.router.add_post("/save-workflow-png", save_workflow_png) app.router.add_get("/channels", get_channels) app.router.add_get("/docs", get_node_doc) + app.router.add_get("/help-docs", get_help_docs) + app.router.add_get("/help-docs/{filename}", get_help_doc_file) app.router.add_post("/prompt", submit_prompt) app.router.add_get("/ws", websocket_handler) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a96220..72a6879 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1970,6 +1970,24 @@ function Flow() { setHelpTabs((prev) => prev.map((t) => t.label === label ? { ...t, content } : t)); }, []); + const openDocByFilename = useCallback(async (filename) => { + const title = filename.replace(/\.md$/i, '').replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + // If already open, just switch to it + setHelpTabs((prev) => { + if (prev.find((t) => t.label === title)) return prev; + return [...prev, { label: title, content: null }]; + }); + setActiveHelpTab(title); + try { + const r = await fetch(`/help-docs/${encodeURIComponent(filename)}`); + if (!r.ok) throw new Error('Not found'); + const doc = await r.json(); + setHelpTabs((prev) => prev.map((t) => t.label === title ? { ...t, content: doc.content } : t)); + } catch { + setHelpTabs((prev) => prev.map((t) => t.label === title ? { ...t, content: `*Could not load ${filename}.*` } : t)); + } + }, []); + const contextValue = useMemo(() => ({ onWidgetChange, onRuntimeValuesChange, @@ -1996,9 +2014,18 @@ function Flow() { setEdges(hydrated.edges); nextIdRef.current = hydrated.nextNodeId; journalContentRef.current = data.journalContent || ''; - setHelpTabs((prev) => prev.map((t) => - t.label === 'Journal' ? { ...t, content: journalContentRef.current } : t, - )); + if (journalContentRef.current) { + setHelpTabs((prev) => { + const existing = prev.find((t) => t.label === 'Journal'); + if (existing) return prev.map((t) => t.label === 'Journal' ? { ...t, content: journalContentRef.current } : t); + return [...prev, { label: 'Journal', type: 'journal', content: journalContentRef.current }]; + }); + setActiveHelpTab('Journal'); + } else { + setHelpTabs((prev) => prev.map((t) => + t.label === 'Journal' ? { ...t, content: '' } : t, + )); + } initializeDynamicNodes(hydrated.nodes); }, [initializeDynamicNodes, setNodes, setEdges]); @@ -2038,6 +2065,20 @@ function Flow() { }).catch((err) => { setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' }); }); + + // Load any .md files from frontend/public/ as help tabs + fetch('/help-docs') + .then((r) => r.ok ? r.json() : []) + .then((docs) => { + if (!docs.length) return; + setHelpTabs((prev) => { + const existing = new Set(prev.map((t) => t.label)); + const newTabs = docs.filter((d) => !existing.has(d.title)).map((d) => ({ label: d.title, content: d.content })); + return newTabs.length ? [...prev, ...newTabs] : prev; + }); + setActiveHelpTab((cur) => cur || docs[0].title); + }) + .catch(() => {}); }, [loadDefaultWorkflow]); const stampLogoOnBlob = useCallback(async (blob) => { @@ -2055,8 +2096,8 @@ function Flow() { ctx.drawImage(img, 0, 0); const margin = 16; - const size = Math.min(128, Math.floor(img.naturalWidth / 6), Math.floor(img.naturalHeight / 6)); - if (size >= 16) { + const size = 64; + if (img.naturalWidth >= size + margin * 2 && img.naturalHeight >= size + margin * 2) { const logoX = img.naturalWidth - size - margin; const logoY = img.naturalHeight - size - margin; const fontSize = Math.max(11, Math.round(size * 0.18)); @@ -3011,6 +3052,7 @@ function Flow() { onTabClose={closeHelpTab} onTabContentChange={updateTabContent} onOpenJournal={openJournalTab} + onOpenDoc={openDocByFilename} /> ); diff --git a/frontend/src/HelpPanelManager.jsx b/frontend/src/HelpPanelManager.jsx index 07380cc..6c4d7b6 100644 --- a/frontend/src/HelpPanelManager.jsx +++ b/frontend/src/HelpPanelManager.jsx @@ -1,13 +1,165 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { marked } from 'marked'; -function JournalTab({ content, onChange }) { +// ── Parse headings from markdown source ────────────────────────────── + +function parseHeadings(md) { + if (!md) return []; + const headings = []; + const lines = md.split('\n'); + for (const line of lines) { + const m = line.match(/^(#{1,6})\s+(.+)/); + if (m) { + const text = m[2].replace(/[*_`~\[\]]/g, '').trim(); + const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, ''); + headings.push({ level: m[1].length, text, id }); + } + } + return headings; +} + +// ── Inject id attributes into rendered HTML headings ───────────────── + +function injectHeadingIds(html, headings) { + let idx = 0; + return html.replace(/<(h[1-6])>/gi, (match, tag) => { + if (idx < headings.length) { + return `<${tag} id="${headings[idx++].id}">`; + } + return match; + }); +} + +// ── Build a tree from flat heading list ────────────────────────────── + +function buildTocTree(headings) { + const root = { children: [] }; + const stack = [{ node: root, level: 0 }]; + for (const h of headings) { + const item = { ...h, children: [] }; + while (stack.length > 1 && stack[stack.length - 1].level >= h.level) stack.pop(); + stack[stack.length - 1].node.children.push(item); + stack.push({ node: item, level: h.level }); + } + return root.children; +} + +// ── TOC sidebar component ──────────────────────────────────────────── + +function TocItem({ item, collapsed, onToggle, onNavigate }) { + const hasChildren = item.children.length > 0; + const isCollapsed = collapsed[item.id]; + return ( +