Documentation Index
Fetch the complete documentation index at: https://docs.ivory.finance/llms.txt
Use this file to discover all available pages before exploring further.
** All API endpoints, WebSocket server, smart block resolvers, and AI orchestrator tools are live at
https://api.ivory.finance. This guide is the handoff spec for the Next.js frontend.Stack
| Layer | Technology | Version |
|---|---|---|
| Editor engine | Tiptap | ^2.x |
| Real-time CRDT | Yjs | ^13.x |
| WebSocket provider | @hocuspocus/provider | ^2.x |
| Charts | Plotly.js | ^2.x |
| Framework | Next.js App Router | ^14 |
| Styling | Tailwind CSS | — |
1. Install dependencies
npm install \
@tiptap/core \
@tiptap/react \
@tiptap/starter-kit \
@tiptap/extension-collaboration \
@tiptap/extension-collaboration-cursor \
@tiptap/extension-placeholder \
@tiptap/extension-typography \
@tiptap/extension-underline \
@tiptap/extension-text-align \
@tiptap/extension-table \
@tiptap/extension-table-row \
@tiptap/extension-table-cell \
@tiptap/extension-table-header \
@hocuspocus/provider \
yjs \
plotly.js-dist \
react-plotly.js
2. File structure
app/
editor/
[docId]/
page.tsx ← editor page (SSR shell, client editor)
loading.tsx
components/
editor/
IvoryEditor.tsx ← main editor component
EditorToolbar.tsx ← formatting toolbar
SlashMenu.tsx ← / command block inserter
CollabCursors.tsx ← other users' cursors
blocks/
SecCitationBlock.tsx ← NodeView for sec_citation
MarketDataBlock.tsx ← NodeView for market_data
AiInsightBlock.tsx ← NodeView for ai_insight
SqlQueryBlock.tsx ← NodeView for sql_query
IvoryChartBlock.tsx ← NodeView for ivory_chart
extensions/
SecCitationExtension.ts
MarketDataExtension.ts
AiInsightExtension.ts
SqlQueryExtension.ts
IvoryChartExtension.ts
sidebar/
DocumentSidebar.tsx ← visibility toggle, version history, sharing
OrchestratorPanel.tsx ← AI chat sidebar
lib/
api/
editor.ts ← API client for document CRUD
blocks.ts ← smart block resolver calls
orchestrator.ts ← orchestrator SSE consumer
hooks/
useDocument.ts
useBlockResolver.ts
useOrchestrator.ts
3. Editor page
// app/editor/[docId]/page.tsx
import { IvoryEditor } from '@/components/editor/IvoryEditor'
import { getDocument } from '@/lib/api/editor'
export default async function EditorPage({ params }: { params: { docId: string } }) {
// Server-side: fetch doc metadata for SEO + initial hydration
const doc = await getDocument(params.docId)
return (
<div className="flex h-screen overflow-hidden">
<main className="flex-1 overflow-y-auto">
<IvoryEditor
docId={params.docId}
initialTitle={doc.title}
initialAst={doc.doc_ast}
visibility={doc.visibility}
myRole={doc.my_role}
/>
</main>
</div>
)
}
4. Main editor component
// components/editor/IvoryEditor.tsx
'use client'
import { useEffect, useMemo, useRef } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import Placeholder from '@tiptap/extension-placeholder'
import * as Y from 'yjs'
import { HocuspocusProvider } from '@hocuspocus/provider'
import { useSession } from 'next-auth/react' // or your auth solution
// Custom extensions
import { SecCitationExtension } from './extensions/SecCitationExtension'
import { MarketDataExtension } from './extensions/MarketDataExtension'
import { AiInsightExtension } from './extensions/AiInsightExtension'
import { SqlQueryExtension } from './extensions/SqlQueryExtension'
import { IvoryChartExtension } from './extensions/IvoryChartExtension'
import { EditorToolbar } from './EditorToolbar'
import { SlashMenu } from './SlashMenu'
import { useAutoSave } from '@/lib/hooks/useDocument'
const COLLAB_WS_URL = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL! // wss://collab.ivory.finance
interface Props {
docId: string
initialTitle: string
initialAst: object
visibility: 'private' | 'workspace' | 'public'
myRole: 'owner' | 'editor' | 'commenter' | 'viewer'
}
export function IvoryEditor({ docId, initialTitle, initialAst, visibility, myRole }: Props) {
const { data: session } = useSession()
const ydoc = useMemo(() => new Y.Doc(), [docId])
// ── Hocuspocus provider ──────────────────────────────────────────────────
const provider = useMemo(() => new HocuspocusProvider({
url: COLLAB_WS_URL,
name: docId, // room = document UUID
document: ydoc,
token: session?.accessToken ?? '',
onAuthenticationFailed: ({ reason }) => {
console.error('Collab auth failed:', reason)
},
}), [docId, session?.accessToken])
useEffect(() => () => provider.destroy(), [provider])
// ── Editor ───────────────────────────────────────────────────────────────
const isReadOnly = myRole === 'viewer' || myRole === 'commenter'
const editor = useEditor({
editable: !isReadOnly,
extensions: [
StarterKit.configure({ history: false }), // history disabled — Yjs handles undo
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: {
name: session?.user?.name ?? 'Anonymous',
color: '#0F766E',
},
}),
Placeholder.configure({ placeholder: 'Start writing, or press / for commands…' }),
// Smart block extensions
SecCitationExtension,
MarketDataExtension,
AiInsightExtension,
SqlQueryExtension,
IvoryChartExtension,
// Slash command menu
SlashMenu,
],
content: initialAst,
})
// ── Auto-save doc_ast to API ─────────────────────────────────────────────
useAutoSave(editor, docId)
return (
<div className="max-w-4xl mx-auto px-8 py-12">
<EditorToolbar editor={editor} />
<EditorContent editor={editor} className="prose prose-slate max-w-none" />
</div>
)
}
5. Auto-save hook
Syncsdoc_ast back to the API after the user stops typing. This makes the document readable by AI agents.
// lib/hooks/useDocument.ts
import { useEffect, useRef } from 'react'
import { Editor } from '@tiptap/core'
import { updateDocument } from '@/lib/api/editor'
export function useAutoSave(editor: Editor | null, docId: string, debounceMs = 2000) {
const timer = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
if (!editor) return
const handler = () => {
clearTimeout(timer.current)
timer.current = setTimeout(async () => {
const ast = editor.getJSON()
await updateDocument(docId, { doc_ast: ast })
}, debounceMs)
}
editor.on('update', handler)
return () => {
editor.off('update', handler)
clearTimeout(timer.current)
}
}, [editor, docId, debounceMs])
}
6. API client
// lib/api/editor.ts
const BASE = process.env.NEXT_PUBLIC_API_URL // https://api.ivory.finance
async function apiFetch(path: string, init?: RequestInit) {
const token = getToken() // from your auth provider / cookie
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...init?.headers,
},
})
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export const getDocument = (id: string) => apiFetch(`/v1/editor/documents/${id}`)
export const listDocuments = (wsId?: string) => apiFetch(`/v1/editor/documents${wsId ? `?workspace_id=${wsId}` : ''}`)
export const createDocument = (body: object) => apiFetch('/v1/editor/documents', { method: 'POST', body: JSON.stringify(body) })
export const updateDocument = (id: string, b: object) => apiFetch(`/v1/editor/documents/${id}`, { method: 'PATCH', body: JSON.stringify(b) })
export const deleteDocument = (id: string) => apiFetch(`/v1/editor/documents/${id}`, { method: 'DELETE' })
export const listWorkspaces = () => apiFetch('/v1/editor/workspaces')
export const createWorkspace = (body: object) => apiFetch('/v1/editor/workspaces', { method: 'POST', body: JSON.stringify(body) })
export const addWorkspaceMember = (wsId: string, body: object) => apiFetch(`/v1/editor/workspaces/${wsId}/members`, { method: 'POST', body: JSON.stringify(body) })
export const listVersions = (docId: string) => apiFetch(`/v1/editor/documents/${docId}/versions`)
export const createVersion = (docId: string, label?: string) => apiFetch(`/v1/editor/documents/${docId}/versions`, { method: 'POST', body: JSON.stringify({ label }) })
export const getVersion = (docId: string, num: number) => apiFetch(`/v1/editor/documents/${docId}/versions/${num}`)
7. Smart block extensions
Each smart block is a TiptapNode extension (marks the node as atom: true so it’s treated as a single non-editable unit) paired with a React NodeView for rich rendering.
7a. SecCitation extension
// components/editor/extensions/SecCitationExtension.ts
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { SecCitationBlock } from '../blocks/SecCitationBlock'
export const SecCitationExtension = Node.create({
name: 'sec_citation',
group: 'block',
atom: true,
draggable: true,
addAttributes() {
return {
block_id: { default: null },
status: { default: 'pending' },
error: { default: null },
accession_number: { default: null },
cik: { default: null },
company_name: { default: null },
form_type: { default: null },
filed_date: { default: null },
period_end: { default: null },
query: { default: null },
excerpts: { default: [] },
filing_url: { default: null },
markdown_url: { default: null },
resolved_at: { default: null },
}
},
parseHTML() { return [{ tag: 'div[data-type="sec_citation"]' }] },
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'sec_citation' })]
},
addNodeView() { return ReactNodeViewRenderer(SecCitationBlock) },
})
7b. SecCitation NodeView renderer
// components/editor/blocks/SecCitationBlock.tsx
import { NodeViewWrapper } from '@tiptap/react'
import { ExternalLink, FileText, ChevronDown, ChevronUp } from 'lucide-react'
import { useState } from 'react'
export function SecCitationBlock({ node, selected }: any) {
const a = node.attrs
const [expanded, setExpanded] = useState(false)
if (a.status === 'pending' || a.status === 'loading') {
return (
<NodeViewWrapper>
<div className="rounded-lg border border-slate-200 p-4 animate-pulse bg-slate-50">
<div className="h-4 bg-slate-200 rounded w-1/3 mb-2" />
<div className="h-3 bg-slate-200 rounded w-2/3" />
</div>
</NodeViewWrapper>
)
}
if (a.status === 'error') {
return (
<NodeViewWrapper>
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700 text-sm">
⚠️ {a.error || 'Failed to load citation'}
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper>
<div className={`rounded-lg border p-4 my-2 transition-shadow ${selected ? 'border-teal-500 shadow-md' : 'border-slate-200 hover:border-slate-300'}`}>
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-teal-600 shrink-0" />
<div>
<span className="font-semibold text-sm text-slate-800">{a.company_name}</span>
<span className="ml-2 text-xs font-medium bg-teal-100 text-teal-700 px-2 py-0.5 rounded">
{a.form_type}
</span>
</div>
</div>
<a
href={a.filing_url}
target="_blank"
rel="noopener noreferrer"
className="text-slate-400 hover:text-teal-600 transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
{/* Meta */}
<div className="text-xs text-slate-500 mb-3">
Filed {a.filed_date} · Period ending {a.period_end}
{a.query && <span> · Query: <em>{a.query}</em></span>}
</div>
{/* Excerpts */}
{a.excerpts?.length > 0 && (
<div>
<blockquote className="border-l-4 border-teal-400 pl-3 text-sm text-slate-700 italic leading-relaxed">
{a.excerpts[0].heading && (
<div className="not-italic font-medium text-xs text-slate-500 mb-1 uppercase tracking-wide">
{a.excerpts[0].heading}
</div>
)}
{a.excerpts[0].text}
</blockquote>
{a.excerpts.length > 1 && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-2 flex items-center gap-1 text-xs text-teal-600 hover:text-teal-800"
>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{expanded ? 'Show less' : `+${a.excerpts.length - 1} more excerpts`}
</button>
)}
{expanded && a.excerpts.slice(1).map((ex: any, i: number) => (
<blockquote key={i} className="mt-3 border-l-4 border-slate-200 pl-3 text-sm text-slate-600 italic leading-relaxed">
{ex.heading && <div className="not-italic font-medium text-xs text-slate-400 mb-1 uppercase">{ex.heading}</div>}
{ex.text}
</blockquote>
))}
</div>
)}
{/* Score badge on last excerpt */}
{a.excerpts?.[0]?.score && (
<div className="mt-2 text-right">
<span className="text-xs text-slate-400">
Relevance {(a.excerpts[0].score * 100).toFixed(0)}%
</span>
</div>
)}
</div>
</NodeViewWrapper>
)
}
7c. MarketData NodeView
// components/editor/blocks/MarketDataBlock.tsx
import { NodeViewWrapper } from '@tiptap/react'
import { TrendingUp } from 'lucide-react'
const METRIC_LABELS: Record<string, string> = {
revenue_ttm_usd_millions: 'Revenue TTM',
net_income_ttm_usd_millions: 'Net Income TTM',
total_assets_usd_millions: 'Total Assets',
total_liabilities_usd_millions: 'Total Liabilities',
cash_and_equivalents_usd_millions: 'Cash & Equivalents',
operating_income_ttm_usd_millions: 'Operating Income TTM',
eps_ttm: 'EPS (TTM)',
net_margin: 'Net Margin',
}
export function MarketDataBlock({ node, selected }: any) {
const a = node.attrs
const metrics = a.metrics || {}
return (
<NodeViewWrapper>
<div className={`rounded-lg border p-4 my-2 ${selected ? 'border-teal-500 shadow-md' : 'border-slate-200'}`}>
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4 text-teal-600" />
<span className="font-semibold text-sm text-slate-800">{a.company_name}</span>
{a.ticker && <span className="text-xs font-mono bg-slate-100 px-2 py-0.5 rounded">{a.ticker}</span>}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{Object.entries(metrics).map(([key, value]: [string, any]) =>
value != null && (
<div key={key} className="bg-slate-50 rounded p-2">
<div className="text-xs text-slate-500 mb-0.5">{METRIC_LABELS[key] ?? key}</div>
<div className="text-sm font-semibold text-slate-800">
{key === 'net_margin' ? `${value}%`
: key === 'eps_ttm' ? `$${value}`
: `$${(value as number).toLocaleString()}M`}
</div>
</div>
)
)}
</div>
<div className="mt-2 text-right text-xs text-slate-400">
Refreshed {new Date(a.refreshed_at).toLocaleDateString()}
</div>
</div>
</NodeViewWrapper>
)
}
7d. AiInsight NodeView
// components/editor/blocks/AiInsightBlock.tsx
import { NodeViewWrapper } from '@tiptap/react'
import { Sparkles } from 'lucide-react'
import ReactMarkdown from 'react-markdown' // npm install react-markdown
export function AiInsightBlock({ node, selected }: any) {
const a = node.attrs
return (
<NodeViewWrapper>
<div className={`rounded-lg border my-2 overflow-hidden ${selected ? 'border-violet-400 shadow-md' : 'border-slate-200'}`}>
<div className="bg-violet-50 border-b border-violet-100 px-4 py-2 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-violet-600" />
<span className="text-xs font-medium text-violet-700">AI Insight</span>
<span className="ml-auto text-xs text-violet-400">{a.model}</span>
</div>
<div className="p-4 prose prose-sm prose-slate max-w-none">
<ReactMarkdown>{a.output || ''}</ReactMarkdown>
</div>
{a.context_summary && (
<div className="border-t border-slate-100 px-4 py-2 text-xs text-slate-400 italic truncate">
Context: {a.context_summary}
</div>
)}
</div>
</NodeViewWrapper>
)
}
7e. SqlQuery NodeView
// components/editor/blocks/SqlQueryBlock.tsx
import { NodeViewWrapper } from '@tiptap/react'
import { Table, Code } from 'lucide-react'
import { useState } from 'react'
export function SqlQueryBlock({ node, selected }: any) {
const a = node.attrs
const [showSql, setShowSql] = useState(false)
if (a.status === 'error') {
return (
<NodeViewWrapper>
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<div className="flex items-center gap-2 text-red-700 text-sm font-medium mb-2">
<Table className="w-4 h-4" /> SQL Query Error
</div>
<pre className="text-xs text-red-600 font-mono">{a.error}</pre>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper>
<div className={`rounded-lg border my-2 overflow-hidden ${selected ? 'border-teal-500 shadow-md' : 'border-slate-200'}`}>
<div className="bg-slate-50 border-b px-4 py-2 flex items-center gap-2">
<Table className="w-4 h-4 text-slate-500" />
<span className="text-xs font-medium text-slate-600">{a.row_count} rows</span>
<button
onClick={() => setShowSql(!showSql)}
className="ml-auto flex items-center gap-1 text-xs text-slate-400 hover:text-slate-600"
>
<Code className="w-3 h-3" />
{showSql ? 'Hide SQL' : 'View SQL'}
</button>
</div>
{showSql && (
<pre className="bg-slate-800 text-green-300 text-xs p-3 font-mono overflow-x-auto">
{a.sql}
</pre>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b">
<tr>
{a.columns?.map((col: string) => (
<th key={col} className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{a.rows?.map((row: string[], i: number) => (
<tr key={i} className="hover:bg-slate-50">
{row.map((cell, j) => (
<td key={j} className="px-4 py-2 text-slate-700 font-mono text-xs">
{cell ?? '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</NodeViewWrapper>
)
}
7f. IvoryChart NodeView
// components/editor/blocks/IvoryChartBlock.tsx
'use client'
import { NodeViewWrapper } from '@tiptap/react'
import dynamic from 'next/dynamic'
// Dynamic import avoids SSR issues with Plotly
const Plot = dynamic(() => import('react-plotly.js'), { ssr: false })
export function IvoryChartBlock({ node, selected }: any) {
const a = node.attrs
return (
<NodeViewWrapper>
<div className={`rounded-lg border my-2 overflow-hidden ${selected ? 'border-teal-500 shadow-md' : 'border-slate-200'}`}>
<div className="bg-slate-50 border-b px-4 py-2 flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">{a.title}</span>
<span className="text-xs bg-slate-200 text-slate-500 px-2 py-0.5 rounded">{a.chart_type}</span>
</div>
{a.plotly_spec ? (
<div className="p-2">
<Plot
data={a.plotly_spec.data}
layout={{
...a.plotly_spec.layout,
autosize: true,
margin: { l: 50, r: 20, t: 30, b: 50 },
}}
useResizeHandler
style={{ width: '100%', height: '320px' }}
config={{ responsive: true, displayModeBar: false }}
/>
</div>
) : (
<div className="p-8 text-center text-slate-400 text-sm">Chart loading…</div>
)}
</div>
</NodeViewWrapper>
)
}
8. Slash command menu
The/ menu lets users insert smart blocks, headings, lists, and other content. Use Tiptap’s Extension + a floating React component triggered by the / keystroke.
// components/editor/SlashMenu.ts
import { Extension } from '@tiptap/core'
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
import { ReactRenderer } from '@tiptap/react'
import tippy from 'tippy.js'
import { SlashMenuList } from './SlashMenuList'
export const SlashMenu = Extension.create({
name: 'slashMenu',
addOptions(): { suggestion: Partial<SuggestionOptions> } {
return {
suggestion: {
char: '/',
command: ({ editor, range, props }) => {
props.command({ editor, range })
},
},
}
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
]
},
})
// components/editor/SlashMenuList.tsx
// Command list shown after '/' is typed
const COMMANDS = [
{ label: 'Heading 1', icon: 'H1', command: ({ editor, range }: any) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run() },
{ label: 'Heading 2', icon: 'H2', command: ({ editor, range }: any) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run() },
{ label: 'Bullet List', icon: '•', command: ({ editor, range }: any) => editor.chain().focus().deleteRange(range).toggleBulletList().run() },
{ label: 'Ordered List', icon: '1.', command: ({ editor, range }: any) => editor.chain().focus().deleteRange(range).toggleOrderedList().run() },
// ── Smart blocks ──
{ label: 'SEC Citation', icon: '📄', command: ({ editor, range }: any) => openBlockModal(editor, range, 'sec_citation') },
{ label: 'Market Data', icon: '📊', command: ({ editor, range }: any) => openBlockModal(editor, range, 'market_data') },
{ label: 'AI Insight', icon: '✨', command: ({ editor, range }: any) => openBlockModal(editor, range, 'ai_insight') },
{ label: 'SQL Query', icon: '🗃️', command: ({ editor, range }: any) => openBlockModal(editor, range, 'sql_query') },
{ label: 'Chart', icon: '📈', command: ({ editor, range }: any) => openBlockModal(editor, range, 'ivory_chart') },
]
openBlockModal opens a small form that collects the required inputs (accession number, ticker, prompt, SQL, etc.) and calls the resolver.
9. Block resolver integration
// lib/api/blocks.ts
const BASE = process.env.NEXT_PUBLIC_API_URL
export async function resolveSecCitation(params: {
accession_number: string
query: string
chunk_limit?: number
doc_id?: string
}) {
const res = await apiFetch('/v1/editor/blocks/resolve/sec_citation', {
method: 'POST',
body: JSON.stringify(params),
})
return res.block // ready to insert into editor
}
export async function resolveMarketData(params: {
cik?: string
ticker?: string
metrics?: string[]
}) {
const res = await apiFetch('/v1/editor/blocks/resolve/market_data', {
method: 'POST',
body: JSON.stringify(params),
})
return res.block
}
export async function resolveAiInsight(params: {
prompt: string
doc_id?: string
llm_model?: string
}) {
const res = await apiFetch('/v1/editor/blocks/resolve/ai_insight', {
method: 'POST',
body: JSON.stringify(params),
})
return res.block
}
export async function resolveSqlQuery(params: { sql: string; params?: any[] }) {
const res = await apiFetch('/v1/editor/blocks/resolve/sql_query', {
method: 'POST',
body: JSON.stringify(params),
})
return res.block
}
export async function resolveChart(params: {
chart_type: string
title: string
spec: object
}) {
const res = await apiFetch('/v1/editor/blocks/resolve/chart', {
method: 'POST',
body: JSON.stringify(params),
})
return res.block
}
async function insertBlock(editor: Editor, range: Range, blockNode: object) {
editor.chain().focus().deleteRange(range).insertContent(blockNode).run()
}
10. Orchestrator sidebar
A slide-in chat panel where the user can ask the AI agent to research and insert content into the document.// components/editor/sidebar/OrchestratorPanel.tsx
'use client'
import { useState, useRef } from 'react'
export function OrchestratorPanel({ docId }: { docId: string }) {
const [query, setQuery] = useState('')
const [plan, setPlan] = useState<any[]>([])
const [steps, setSteps] = useState<any[]>([])
const [answer, setAnswer] = useState('')
const [loading, setLoading] = useState(false)
const abortRef = useRef<AbortController>()
const run = async () => {
if (!query.trim() || loading) return
setLoading(true); setPlan([]); setSteps([]); setAnswer('')
abortRef.current = new AbortController()
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/rag/orchestrate`, {
method: 'POST',
signal: abortRef.current.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
'Accept': 'text/event-stream',
},
body: JSON.stringify({
query,
// Tell the orchestrator which document it's working on
// so write_to_editor_document knows the target
chat_id: `doc_${docId}`,
}),
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const event = JSON.parse(line.slice(6))
switch (event.type) {
case 'plan': setPlan(event.steps); break
case 'agent_step': setSteps(s => [...s.filter(x => x.tool !== event.tool || x.status === 'done'), event]); break
case 'token': setAnswer(a => a + event.token); break
case 'done': setLoading(false); break
case 'error': setAnswer(`Error: ${event.detail}`); setLoading(false); break
}
}
}
}
return (
<aside className="w-96 border-l flex flex-col h-screen bg-white">
<div className="p-4 border-b font-semibold text-slate-800 flex items-center gap-2">
✨ AI Research Assistant
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{/* Research plan */}
{plan.length > 0 && (
<div className="bg-teal-50 rounded-lg p-3">
<div className="text-xs font-semibold text-teal-700 mb-2 uppercase tracking-wide">Research plan</div>
{plan.map((step, i) => (
<div key={i} className="text-xs text-teal-800 flex gap-2">
<span className="text-teal-400">{i + 1}.</span> {step.task}
</div>
))}
</div>
)}
{/* Live tool steps */}
{steps.map((step, i) => (
<div key={i} className={`text-xs rounded px-3 py-2 flex items-center gap-2 ${step.status === 'running' ? 'bg-amber-50 text-amber-700' : 'bg-slate-50 text-slate-600'}`}>
{step.status === 'running' ? '⟳' : '✓'} {step.tool}
{step.status === 'done' && step.summary && (
<span className="text-slate-400 truncate">{step.summary.slice(0, 80)}</span>
)}
</div>
))}
{/* Answer */}
{answer && (
<div className="prose prose-sm prose-slate max-w-none text-sm whitespace-pre-wrap">
{answer}
</div>
)}
</div>
{/* Input */}
<div className="p-4 border-t">
<textarea
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Ask AI to research, analyse, or write into this document…"
className="w-full resize-none border rounded-lg p-3 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
rows={3}
onKeyDown={e => e.key === 'Enter' && e.metaKey && run()}
/>
<button
onClick={run}
disabled={loading}
className="mt-2 w-full bg-teal-600 text-white text-sm font-medium rounded-lg py-2 hover:bg-teal-700 disabled:opacity-50"
>
{loading ? 'Researching…' : 'Research ⌘↵'}
</button>
</div>
</aside>
)
}
11. Document sidebar
Settings panel for visibility, sharing, and version history.// components/editor/sidebar/DocumentSidebar.tsx
'use client'
import { useState } from 'react'
import { updateDocument, listVersions, createVersion, getVersion } from '@/lib/api/editor'
export function DocumentSidebar({ docId, visibility, myRole, editor }: any) {
const [vis, setVis] = useState(visibility)
const [versions, setVersions] = useState<any[]>([])
const changeVisibility = async (newVis: string) => {
await updateDocument(docId, { visibility: newVis })
setVis(newVis)
}
const saveVersion = async () => {
await createVersion(docId, `Snapshot ${new Date().toLocaleString()}`)
}
const loadVersions = async () => {
const v = await listVersions(docId)
setVersions(v)
}
const restoreVersion = async (versionNum: number) => {
const v = await getVersion(docId, versionNum)
editor.commands.setContent(v.doc_ast)
await updateDocument(docId, { doc_ast: v.doc_ast })
}
return (
<aside className="w-64 border-l p-4 space-y-6 bg-white h-screen overflow-y-auto">
{/* Visibility */}
{myRole === 'owner' && (
<section>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Visibility</div>
{(['private', 'workspace', 'public'] as const).map(v => (
<button
key={v}
onClick={() => changeVisibility(v)}
className={`w-full text-left text-sm px-3 py-2 rounded-lg mb-1 transition-colors ${vis === v ? 'bg-teal-50 text-teal-700 font-medium' : 'text-slate-600 hover:bg-slate-50'}`}
>
{v === 'private' ? '🔒 Private' : ''}
{v === 'workspace' ? '👥 Workspace' : ''}
{v === 'public' ? '🌐 Public' : ''}
</button>
))}
{vis === 'public' && (
<p className="text-xs text-amber-600 mt-1">
Anyone can discover and read this document via AI search.
</p>
)}
</section>
)}
{/* Version history */}
<section>
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Versions</div>
{myRole !== 'viewer' && (
<button onClick={saveVersion} className="text-xs text-teal-600 hover:text-teal-800">Save now</button>
)}
</div>
<button onClick={loadVersions} className="text-xs text-slate-500 hover:text-slate-700 mb-2">
Load history
</button>
{versions.map(v => (
<div key={v.id} className="flex items-center justify-between text-xs text-slate-600 py-1 border-b border-slate-100">
<div>
<span className="font-medium">v{v.version_num}</span>
{v.label && <span className="ml-1 text-slate-400">{v.label}</span>}
<div className="text-slate-400">{v.created_by_type} · {new Date(v.created_at).toLocaleDateString()}</div>
</div>
{myRole === 'owner' && (
<button onClick={() => restoreVersion(v.version_num)} className="text-teal-600 hover:text-teal-800">
Restore
</button>
)}
</div>
))}
</section>
</aside>
)
}
12. Environment variables
Add to.env.local:
NEXT_PUBLIC_API_URL=https://api.ivory.finance
NEXT_PUBLIC_HOCUSPOCUS_URL=wss://collab.ivory.finance
13. Complete layout
Putting it all together:// app/editor/[docId]/page.tsx (final)
'use client'
import { IvoryEditor } from '@/components/editor/IvoryEditor'
import { OrchestratorPanel } from '@/components/editor/sidebar/OrchestratorPanel'
import { DocumentSidebar } from '@/components/editor/sidebar/DocumentSidebar'
import { useState } from 'react'
export default function EditorPage({ params }: { params: { docId: string } }) {
const [showAI, setShowAI] = useState(false)
const [showDoc, setShowDoc] = useState(true)
return (
<div className="flex h-screen overflow-hidden bg-white">
{/* Main editor */}
<div className="flex-1 overflow-y-auto">
<IvoryEditor docId={params.docId} />
</div>
{/* Document settings sidebar */}
{showDoc && <DocumentSidebar docId={params.docId} />}
{/* AI orchestrator panel */}
{showAI && <OrchestratorPanel docId={params.docId} />}
{/* Toggle buttons — top right */}
<div className="absolute top-4 right-4 flex gap-2">
<button onClick={() => setShowAI(!showAI)} className="text-xs bg-violet-100 text-violet-700 px-3 py-1.5 rounded-full font-medium">
✨ AI
</button>
<button onClick={() => setShowDoc(!showDoc)} className="text-xs bg-slate-100 text-slate-600 px-3 py-1.5 rounded-full font-medium">
⚙️ Settings
</button>
</div>
</div>
)
}
14. Quick checklist for the frontend dev
Implement 5 Tiptap extensions
One
Node.create() per block type (sec_citation, market_data, ai_insight, sql_query, ivory_chart). Refer to the extension skeleton in Smart Blocks or use GET /v1/editor/blocks/schemas for the exact attrs list.Implement 5 NodeView renderers
React components rendering each block type. Skeletons above — style to match Ivory design system.
Wire Hocuspocus provider
Pass the document UUID as the room name and the JWT as the token. Handle
onAuthenticationFailed.Implement auto-save
Use the
useAutoSave hook — debounce 2s, PATCH /v1/editor/documents/{id} with doc_ast.Build slash menu
/ keystroke → command list → modal for smart block inputs → call resolver → insert resolved block node.Build orchestrator panel
SSE consumer for
/v1/rag/orchestrate — render plan, tool steps, streaming answer.API endpoints summary
| What | Method + Path | Auth |
|---|---|---|
| List documents | GET /v1/editor/documents | JWT |
| Create document | POST /v1/editor/documents | JWT |
| Get document | GET /v1/editor/documents/{id} | JWT (public = optional) |
| Update document | PATCH /v1/editor/documents/{id} | JWT, editor+ |
| Delete document | DELETE /v1/editor/documents/{id} | JWT, owner |
| Share document | POST /v1/editor/documents/{id}/members | JWT, owner |
| List versions | GET /v1/editor/documents/{id}/versions | JWT |
| Create version | POST /v1/editor/documents/{id}/versions | JWT, editor+ |
| Restore version | GET version + PATCH document | JWT, editor+ |
| Resolve sec_citation | POST /v1/editor/blocks/resolve/sec_citation | Optional JWT |
| Resolve market_data | POST /v1/editor/blocks/resolve/market_data | Optional JWT |
| Resolve ai_insight | POST /v1/editor/blocks/resolve/ai_insight | JWT required |
| Resolve sql_query | POST /v1/editor/blocks/resolve/sql_query | JWT required |
| Resolve chart | POST /v1/editor/blocks/resolve/chart | Optional JWT |
| Block schemas | GET /v1/editor/blocks/schemas | None |
| Orchestrate (SSE) | POST /v1/rag/orchestrate | JWT |
| Workspaces CRUD | GET/POST /v1/editor/workspaces | JWT |
| Hocuspocus auth | POST /v1/editor/hocuspocus/auth | Internal |

