mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev
This commit is contained in:
commit
758a2779f4
18 changed files with 1388 additions and 39 deletions
|
|
@ -39,7 +39,7 @@ import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.
|
|||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
import { search } from '@x/core/dist/search/search.js';
|
||||
import { versionHistory, voice } from '@x/core';
|
||||
import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
|
||||
/**
|
||||
|
|
@ -705,6 +705,9 @@ export function setupIpcHandlers() {
|
|||
const schedule = await classifySchedule(args.instruction);
|
||||
return { schedule };
|
||||
},
|
||||
'inline-task:process': async (_event, args) => {
|
||||
return await processRowboatInstruction(args.instruction, args.noteContent, args.notePath);
|
||||
},
|
||||
'voice:getConfig': async () => {
|
||||
return voice.getVoiceConfig();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^1.6.10",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
|
|||
|
|
@ -3869,6 +3869,7 @@ function App() {
|
|||
>
|
||||
<MarkdownEditor
|
||||
content={tabContent}
|
||||
notePath={tab.path}
|
||||
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
|
||||
onPrimaryHeadingCommit={() => {
|
||||
untitledRenameReadyPathsRef.current.add(tab.path)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import TaskList from '@tiptap/extension-task-list'
|
|||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
import { TableBlockExtension } from '@/extensions/table-block'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
|
|
@ -136,6 +140,14 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
blocks.push(listLines.join('\n'))
|
||||
} else if (node.type === 'taskBlock') {
|
||||
blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'imageBlock') {
|
||||
blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'embedBlock') {
|
||||
blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'chartBlock') {
|
||||
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'tableBlock') {
|
||||
blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'codeBlock') {
|
||||
const lang = (node.attrs?.language as string) || ''
|
||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||
|
|
@ -220,6 +232,7 @@ interface MarkdownEditorProps {
|
|||
frontmatter?: string | null
|
||||
onFrontmatterChange?: (raw: string | null) => void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
notePath?: string
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -311,6 +324,7 @@ export function MarkdownEditor({
|
|||
frontmatter,
|
||||
onFrontmatterChange,
|
||||
onExport,
|
||||
notePath,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -429,6 +443,10 @@ export function MarkdownEditor({
|
|||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path) => {
|
||||
|
|
@ -912,24 +930,17 @@ export function MarkdownEditor({
|
|||
}
|
||||
|
||||
if (activeRowboatMention) {
|
||||
// Classify schedule intent for new blocks
|
||||
const blockData: Record<string, unknown> = { instruction }
|
||||
try {
|
||||
const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction })
|
||||
if (result.schedule) {
|
||||
const { label, ...rest } = result.schedule
|
||||
blockData.schedule = rest
|
||||
blockData['schedule-label'] = label
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RowboatAdd] Schedule classification failed:', error)
|
||||
}
|
||||
// Insert a temporary processing block
|
||||
const blockData: Record<string, unknown> = { instruction, processing: true }
|
||||
|
||||
const insertFrom = activeRowboatMention.range.from
|
||||
const insertTo = activeRowboatMention.range.to
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
{ from: activeRowboatMention.range.from, to: activeRowboatMention.range.to },
|
||||
{ from: insertFrom, to: insertTo },
|
||||
[
|
||||
{ type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } },
|
||||
{ type: 'paragraph' },
|
||||
|
|
@ -937,17 +948,124 @@ export function MarkdownEditor({
|
|||
)
|
||||
.run()
|
||||
|
||||
// Mark note as live
|
||||
if (onFrontmatterChange) {
|
||||
const fields = extractAllFrontmatterValues(frontmatter ?? null)
|
||||
fields['live_note'] = 'true'
|
||||
onFrontmatterChange(buildFrontmatter(fields))
|
||||
}
|
||||
|
||||
setActiveRowboatMention(null)
|
||||
setRowboatAnchorTop(null)
|
||||
|
||||
// Get editor content for the agent
|
||||
const editorContent = editor.storage.markdown?.getMarkdown?.() ?? ''
|
||||
|
||||
// Helper to find the processing block
|
||||
const findProcessingBlock = (): number | null => {
|
||||
let pos: number | null = null
|
||||
editor.state.doc.descendants((node, p) => {
|
||||
if (pos !== null) return false
|
||||
if (node.type.name === 'taskBlock') {
|
||||
try {
|
||||
const data = JSON.parse(node.attrs.data || '{}')
|
||||
if (data.instruction === instruction && data.processing === true) {
|
||||
pos = p
|
||||
return false
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
})
|
||||
return pos
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the copilot assistant for both one-time and recurring tasks
|
||||
const result = await window.ipc.invoke('inline-task:process', {
|
||||
instruction,
|
||||
noteContent: editorContent,
|
||||
notePath: notePath ?? '',
|
||||
})
|
||||
|
||||
const currentPos = findProcessingBlock()
|
||||
if (currentPos === null) return
|
||||
|
||||
const node = editor.state.doc.nodeAt(currentPos)
|
||||
if (!node) return
|
||||
|
||||
if (result.schedule) {
|
||||
// Recurring/scheduled task: update block with schedule, write target tags to disk
|
||||
const targetId = Math.random().toString(36).slice(2, 10)
|
||||
const updatedData: Record<string, unknown> = {
|
||||
instruction: result.instruction,
|
||||
schedule: result.schedule,
|
||||
'schedule-label': result.scheduleLabel,
|
||||
targetId,
|
||||
}
|
||||
const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, {
|
||||
data: JSON.stringify(updatedData),
|
||||
})
|
||||
editor.view.dispatch(tr)
|
||||
|
||||
// Mark note as live
|
||||
if (onFrontmatterChange) {
|
||||
const fields = extractAllFrontmatterValues(frontmatter ?? null)
|
||||
fields['live_note'] = 'true'
|
||||
onFrontmatterChange(buildFrontmatter(fields))
|
||||
}
|
||||
|
||||
// Write target tags directly to the file on disk after a short delay
|
||||
// to let the editor save the updated content first
|
||||
if (notePath) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const file = await window.ipc.invoke('workspace:readFile', { path: notePath })
|
||||
const content = file.data
|
||||
const openTag = `<!--task-target:${targetId}-->`
|
||||
const closeTag = `<!--/task-target:${targetId}-->`
|
||||
|
||||
// Only add if not already present
|
||||
if (content.includes(openTag)) return
|
||||
|
||||
// Find the task block in the raw markdown and insert target tags after it
|
||||
const blockJson = JSON.stringify(updatedData)
|
||||
const blockStart = content.indexOf('```task\n' + blockJson)
|
||||
if (blockStart !== -1) {
|
||||
const blockEnd = content.indexOf('\n```', blockStart + 8)
|
||||
if (blockEnd !== -1) {
|
||||
const insertAt = blockEnd + 4 // after the closing ```
|
||||
const before = content.slice(0, insertAt)
|
||||
const after = content.slice(insertAt)
|
||||
const updated = before + '\n\n' + openTag + '\n' + closeTag + after
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
data: updated,
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RowboatAdd] Failed to write target tags:', err)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
} else {
|
||||
// One-time task: remove the processing block, insert response in its place
|
||||
const insertPos = currentPos
|
||||
const deleteEnd = currentPos + node.nodeSize
|
||||
editor.chain().focus().deleteRange({ from: insertPos, to: deleteEnd }).run()
|
||||
|
||||
if (result.response) {
|
||||
editor.chain().insertContentAt(insertPos, result.response).run()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RowboatAdd] Processing failed:', error)
|
||||
|
||||
// Remove the processing block on error
|
||||
const currentPos = findProcessingBlock()
|
||||
if (currentPos !== null) {
|
||||
const node = editor.state.doc.nodeAt(currentPos)
|
||||
if (node) {
|
||||
editor.chain().focus().deleteRange({ from: currentPos, to: currentPos + node.nodeSize }).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange])
|
||||
}, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange, notePath])
|
||||
|
||||
const handleRowboatRemove = useCallback(() => {
|
||||
if (!editor || !rowboatBlockEdit) return
|
||||
|
|
|
|||
173
apps/x/apps/renderer/src/extensions/chart-block.tsx
Normal file
173
apps/x/apps/renderer/src/extensions/chart-block.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, BarChart3 } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
LineChart, Line,
|
||||
BarChart, Bar,
|
||||
PieChart, Pie, Cell,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts'
|
||||
|
||||
const CHART_COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#0088fe', '#00c49f']
|
||||
|
||||
function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.ChartBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.ChartBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const [fileData, setFileData] = useState<Record<string, unknown>[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.source) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
;(window as unknown as { ipc: { invoke: (channel: string, args: Record<string, string>) => Promise<string> } })
|
||||
.ipc.invoke('workspace:readFile', { path: config.source, encoding: 'utf-8' })
|
||||
.then((content: string) => {
|
||||
const parsed = JSON.parse(content)
|
||||
if (Array.isArray(parsed)) {
|
||||
setFileData(parsed)
|
||||
} else {
|
||||
setError('Source file must contain a JSON array')
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message || 'Failed to load data file')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [config?.source])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
|
||||
<div className="chart-block-card chart-block-error">
|
||||
<BarChart3 size={16} />
|
||||
<span>Invalid chart block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const data = config.data || fileData
|
||||
|
||||
const renderChart = () => {
|
||||
if (loading) return <div className="chart-block-loading">Loading data...</div>
|
||||
if (error) return <div className="chart-block-error-msg">{error}</div>
|
||||
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
{config!.chart === 'line' ? (
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={config!.x} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey={config!.y} stroke="#8884d8" />
|
||||
</LineChart>
|
||||
) : config!.chart === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={config!.x} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey={config!.y} fill="#8884d8" />
|
||||
</BarChart>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Pie data={data} dataKey={config!.y} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
|
||||
<div className="chart-block-card">
|
||||
<button
|
||||
className="chart-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete chart block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.title && <div className="chart-block-title">{config.title}</div>}
|
||||
{renderChart()}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChartBlockExtension = Node.create({
|
||||
name: 'chartBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-chart')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'chart-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ChartBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```chart\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
143
apps/x/apps/renderer/src/extensions/embed-block.tsx
Normal file
143
apps/x/apps/renderer/src/extensions/embed-block.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, ExternalLink } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function getEmbedUrl(provider: string, url: string): string | null {
|
||||
if (provider === 'youtube') {
|
||||
// Handle youtube.com/watch?v=X and youtu.be/X
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
|
||||
if (match) return `https://www.youtube.com/embed/${match[1]}`
|
||||
}
|
||||
if (provider === 'figma') {
|
||||
// Convert www.figma.com/design/:key/... → embed.figma.com/design/:key?embed-host=rowboat
|
||||
const figmaMatch = url.match(/figma\.com\/(design|board|proto)\/([\w-]+)/)
|
||||
if (figmaMatch) {
|
||||
return `https://embed.figma.com/${figmaMatch[1]}/${figmaMatch[2]}?embed-host=rowboat`
|
||||
}
|
||||
// Legacy /file/ URLs
|
||||
const legacyMatch = url.match(/figma\.com\/file\/([\w-]+)/)
|
||||
if (legacyMatch) {
|
||||
return `https://embed.figma.com/design/${legacyMatch[1]}?embed-host=rowboat`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.EmbedBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.EmbedBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
|
||||
<div className="embed-block-card embed-block-error">
|
||||
<ExternalLink size={16} />
|
||||
<span>Invalid embed block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const embedUrl = getEmbedUrl(config.provider, config.url)
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
|
||||
<div className="embed-block-card">
|
||||
<button
|
||||
className="embed-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete embed block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{embedUrl ? (
|
||||
<div className="embed-block-iframe-container">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
className="embed-block-iframe"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={config.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="embed-block-link"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{config.url}
|
||||
</a>
|
||||
)}
|
||||
{config.caption && (
|
||||
<div className="embed-block-caption">{config.caption}</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmbedBlockExtension = Node.create({
|
||||
name: 'embedBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-embed')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'embed-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(EmbedBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```embed\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
104
apps/x/apps/renderer/src/extensions/image-block.tsx
Normal file
104
apps/x/apps/renderer/src/extensions/image-block.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, ImageIcon } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function ImageBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.ImageBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.ImageBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
|
||||
<div className="image-block-card image-block-error">
|
||||
<ImageIcon size={16} />
|
||||
<span>Invalid image block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
|
||||
<div className="image-block-card">
|
||||
<button
|
||||
className="image-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete image block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<img
|
||||
src={config.src}
|
||||
alt={config.alt || ''}
|
||||
className="image-block-img"
|
||||
/>
|
||||
{config.caption && (
|
||||
<div className="image-block-caption">{config.caption}</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const ImageBlockExtension = Node.create({
|
||||
name: 'imageBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-image')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```image\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
124
apps/x/apps/renderer/src/extensions/table-block.tsx
Normal file
124
apps/x/apps/renderer/src/extensions/table-block.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Table2 } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function TableBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.TableBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.TableBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
|
||||
<div className="table-block-card table-block-error">
|
||||
<Table2 size={16} />
|
||||
<span>Invalid table block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
|
||||
<div className="table-block-card">
|
||||
<button
|
||||
className="table-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete table block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.title && <div className="table-block-title">{config.title}</div>}
|
||||
<div className="table-block-scroll">
|
||||
<table className="table-block-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{config.columns.map((col) => (
|
||||
<th key={col}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.data.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{config!.columns.map((col) => (
|
||||
<td key={col}>{String(row[col] ?? '')}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{config.data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={config.columns.length} className="table-block-empty">
|
||||
No data
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const TableBlockExtension = Node.create({
|
||||
name: 'tableBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-table')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'table-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TableBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```table\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1,22 +1,39 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { CalendarClock, X } from 'lucide-react'
|
||||
import { CalendarClock, Loader2, X } from 'lucide-react'
|
||||
import { inlineTask } from '@x/shared'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let instruction = ''
|
||||
let scheduleLabel = ''
|
||||
let processing = false
|
||||
let lastRunAt = ''
|
||||
|
||||
try {
|
||||
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
|
||||
instruction = parsed.instruction
|
||||
scheduleLabel = parsed['schedule-label'] ?? ''
|
||||
processing = parsed.processing ?? false
|
||||
lastRunAt = parsed.lastRunAt ?? ''
|
||||
} catch {
|
||||
// Fallback: show raw data
|
||||
instruction = raw
|
||||
}
|
||||
|
||||
const lastRunLabel = lastRunAt ? formatDateTime(lastRunAt) : ''
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="task-block-wrapper" data-type="task-block">
|
||||
<div className="task-block-card">
|
||||
|
|
@ -29,10 +46,17 @@ function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unk
|
|||
</button>
|
||||
<div className="task-block-content">
|
||||
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
|
||||
{scheduleLabel && (
|
||||
{processing && (
|
||||
<span className="task-block-schedule">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
processing…
|
||||
</span>
|
||||
)}
|
||||
{!processing && scheduleLabel && (
|
||||
<span className="task-block-schedule">
|
||||
<CalendarClock size={12} />
|
||||
{scheduleLabel}
|
||||
{lastRunLabel && <span className="task-block-last-run"> · last ran {lastRunLabel}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -608,6 +608,234 @@
|
|||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-last-run {
|
||||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
}
|
||||
|
||||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-card {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background-color: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
cursor: default;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover {
|
||||
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-delete:hover {
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Image block */
|
||||
.tiptap-editor .ProseMirror .image-block-img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-caption {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Embed block */
|
||||
.tiptap-editor .ProseMirror .embed-block-iframe-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-caption {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Chart block */
|
||||
.tiptap-editor .ProseMirror .chart-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .chart-block-loading,
|
||||
.tiptap-editor .ProseMirror .chart-block-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
font-size: 13px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .chart-block-error-msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .chart-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Table block */
|
||||
.tiptap-editor .ProseMirror .table-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table td {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-empty {
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
padding: 16px 10px !important;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .tiptap-editor .ProseMirror {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
|
|
|
|||
|
|
@ -173,6 +173,56 @@ Documents are stored in \`~/.rowboat/knowledge/\` with subfolders:
|
|||
- \`Topics/\` - Subject matter notes
|
||||
- Root level for general documents
|
||||
|
||||
## Rich Blocks
|
||||
|
||||
Notes support rich block types beyond standard Markdown. Blocks are fenced code blocks with a language identifier and a JSON body. Use these when the user asks for visual content like charts, tables, images, or embeds.
|
||||
|
||||
### Image Block
|
||||
Displays an image with optional alt text and caption.
|
||||
\`\`\`image
|
||||
{"src": "https://example.com/photo.png", "alt": "Description", "caption": "Optional caption"}
|
||||
\`\`\`
|
||||
- \`src\` (required): URL or relative path to the image
|
||||
- \`alt\` (optional): Alt text
|
||||
- \`caption\` (optional): Caption displayed below the image
|
||||
|
||||
### Embed Block
|
||||
Embeds external content (YouTube videos, Figma designs, or generic links).
|
||||
\`\`\`embed
|
||||
{"provider": "youtube", "url": "https://www.youtube.com/watch?v=VIDEO_ID", "caption": "Video title"}
|
||||
\`\`\`
|
||||
- \`provider\` (required): \`"youtube"\`, \`"figma"\`, or \`"generic"\`
|
||||
- \`url\` (required): Full URL to the content
|
||||
- \`caption\` (optional): Caption displayed below the embed
|
||||
- YouTube and Figma render as iframes; generic shows a link card
|
||||
|
||||
### Chart Block
|
||||
Renders a chart from inline data.
|
||||
\`\`\`chart
|
||||
{"chart": "bar", "title": "Q1 Revenue", "data": [{"month": "Jan", "revenue": 50000}, {"month": "Feb", "revenue": 62000}], "x": "month", "y": "revenue"}
|
||||
\`\`\`
|
||||
- \`chart\` (required): \`"line"\`, \`"bar"\`, or \`"pie"\`
|
||||
- \`title\` (optional): Chart title
|
||||
- \`data\` (optional): Array of objects with the data points
|
||||
- \`source\` (optional): Relative path to a JSON file containing the data array (alternative to inline data)
|
||||
- \`x\` (required): Key name for the x-axis / label field
|
||||
- \`y\` (required): Key name for the y-axis / value field
|
||||
|
||||
### Table Block
|
||||
Renders a styled table from structured data.
|
||||
\`\`\`table
|
||||
{"title": "Team", "columns": ["name", "role"], "data": [{"name": "Alice", "role": "Eng"}, {"name": "Bob", "role": "Design"}]}
|
||||
\`\`\`
|
||||
- \`columns\` (required): Array of column names (determines display order)
|
||||
- \`data\` (required): Array of row objects
|
||||
- \`title\` (optional): Table title
|
||||
|
||||
### Block Guidelines
|
||||
- The JSON must be valid and on a single line (no pretty-printing)
|
||||
- Insert blocks using \`workspace-editFile\` just like any other content
|
||||
- When the user asks for a chart, table, or embed — use blocks rather than plain Markdown tables or image links
|
||||
- When editing a note that already contains blocks, preserve them unless the user asks to change them
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Writing style:**
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ export function getRaw(): string {
|
|||
.map(name => ` ${name}:\n type: builtin\n name: ${name}`)
|
||||
.join('\n');
|
||||
|
||||
const now = new Date();
|
||||
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const nowISO = now.toISOString();
|
||||
const defaultEndISO = defaultEnd.toISOString();
|
||||
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
|
|
@ -12,16 +19,74 @@ ${toolEntries}
|
|||
---
|
||||
# Task
|
||||
|
||||
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and execute it.
|
||||
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and either execute it immediately or set it up as a recurring task.
|
||||
|
||||
# Instructions
|
||||
# Two Modes
|
||||
|
||||
1. You will receive the full content of a knowledge note and a specific instruction extracted from a \`@rowboat <instruction>\` line in that note.
|
||||
2. Execute the instruction using your full workspace tool set. You have access to read files, edit files, search, run commands, etc.
|
||||
3. Use the surrounding note content as context for the task.
|
||||
4. Your response will be inserted directly into the note below the @rowboat instruction. Write your output as note content — it must read naturally as part of the document.
|
||||
5. NEVER include meta-commentary, thinking out loud, or narration about what you're doing. No "Let me look that up", "Here are the details", "I found the following", etc. Just write the content itself.
|
||||
6. Keep the result concise and well-formatted in markdown.
|
||||
7. Do not modify the original note file — the service will handle inserting your response.
|
||||
## 1. One-Time Tasks (no scheduling intent)
|
||||
For instructions that should be executed immediately (e.g., "summarize this note", "look up the weather"):
|
||||
- Execute the instruction using your full workspace tool set
|
||||
- Return the result as markdown content
|
||||
- Do NOT include any schedule or instruction markers
|
||||
|
||||
## 2. Recurring/Scheduled Tasks (has scheduling intent)
|
||||
For instructions that imply a recurring or future-scheduled task (e.g., "every morning at 8am check emails", "remind me tomorrow at 3pm"):
|
||||
- Do NOT execute the task — only set up the schedule
|
||||
- You MUST include BOTH markers described below
|
||||
- Do NOT include any other content besides the markers
|
||||
|
||||
# Markers for Scheduled Tasks
|
||||
|
||||
When the instruction has scheduling intent, your response MUST contain these markers and nothing else:
|
||||
|
||||
## Schedule Marker (required)
|
||||
<!--rowboat-schedule:{"type":"...","label":"..."}-->
|
||||
|
||||
Schedule types:
|
||||
1. "cron" — recurring: \`<!--rowboat-schedule:{"type":"cron","expression":"<5-field cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<label>"}-->\`
|
||||
"startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}).
|
||||
Example: "every morning at 8am" → \`<!--rowboat-schedule:{"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until ${defaultEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}"}-->\`
|
||||
|
||||
2. "window" — recurring with time window: \`<!--rowboat-schedule:{"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<label>"}-->\`
|
||||
|
||||
3. "once" — future one-time: \`<!--rowboat-schedule:{"type":"once","runAt":"<ISO 8601>","label":"<label>"}-->\`
|
||||
|
||||
The "label" must be a short plain-English description starting with "runs" (e.g., "runs daily at 8 AM until Mar 24").
|
||||
|
||||
## Instruction Marker (required for scheduled tasks)
|
||||
<!--rowboat-instruction:the refined instruction text-->
|
||||
|
||||
This is the instruction that will be executed on each scheduled run. You may refine/clarify the original instruction to make it more specific and actionable for the background agent that will execute it. For example:
|
||||
- User says "check my emails every morning" → \`<!--rowboat-instruction:Check for new emails and summarize any important ones.-->\`
|
||||
- User says "news about claude daily" → \`<!--rowboat-instruction:Search for the latest news about Anthropic's Claude AI and list the top stories with sources.-->\`
|
||||
|
||||
If the instruction is already clear and actionable, you can keep it as-is.
|
||||
|
||||
# Context
|
||||
|
||||
Current local time: ${localNow}
|
||||
Timezone: ${tz}
|
||||
Current UTC time: ${nowISO}
|
||||
|
||||
# Output Rules
|
||||
|
||||
- For one-time tasks: write output as note content — it must read naturally as part of the document. NEVER include meta-commentary. Keep concise and well-formatted in markdown.
|
||||
- For scheduled tasks: output ONLY the two markers (schedule + instruction), nothing else.
|
||||
- Do not modify the original note file — the system handles all insertions.
|
||||
|
||||
# Target Regions
|
||||
|
||||
For recurring/scheduled tasks, the note will contain a **target region** delimited by HTML comment tags:
|
||||
|
||||
\`\`\`
|
||||
<!--task-target:TARGETID-->
|
||||
...existing content...
|
||||
<!--/task-target:TARGETID-->
|
||||
\`\`\`
|
||||
|
||||
When you see a target region associated with your task (during a scheduled run), your response MUST be the replacement content for that region. You should:
|
||||
- Write content that replaces whatever is currently between the tags
|
||||
- Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate)
|
||||
- Do NOT include the target tags themselves in your response
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,8 @@ interface InlineTask {
|
|||
startLine: number;
|
||||
/** Line index of the closing ``` fence */
|
||||
endLine: number;
|
||||
/** Target region ID for recurring tasks */
|
||||
targetId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -183,7 +185,7 @@ interface InlineTask {
|
|||
* Returns { instruction, schedule } or null if not valid JSON.
|
||||
* Also supports legacy @rowboat format.
|
||||
*/
|
||||
function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null } | null {
|
||||
function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null; targetId: string | null } | null {
|
||||
const raw = contentLines.join('\n').trim();
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
|
|
@ -193,6 +195,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched
|
|||
instruction: parsed.data.instruction,
|
||||
schedule: parsed.data.schedule ? { ...parsed.data.schedule, label: parsed.data['schedule-label'] ?? '' } as InlineTaskSchedule : null,
|
||||
lastRunAt: parsed.data.lastRunAt ?? null,
|
||||
targetId: parsed.data.targetId ?? null,
|
||||
};
|
||||
}
|
||||
// Fallback for blocks that have instruction but don't fully match schema
|
||||
|
|
@ -201,6 +204,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched
|
|||
instruction: data.instruction,
|
||||
schedule: data.schedule ?? null,
|
||||
lastRunAt: data.lastRunAt ?? null,
|
||||
targetId: data.targetId ?? null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -227,7 +231,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched
|
|||
const rawInstruction = firstRowboatLine?.trim() ?? instructionLines.join('\n').trim();
|
||||
const instruction = rawInstruction.replace(/^@rowboat:?\s*/, '');
|
||||
if (!instruction) return null;
|
||||
return { instruction, schedule, lastRunAt: null };
|
||||
return { instruction, schedule, lastRunAt: null, targetId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -308,16 +312,16 @@ function findPendingTasks(body: string): InlineTask[] {
|
|||
|
||||
const parsed = parseBlockContent(contentLines);
|
||||
if (parsed) {
|
||||
const { instruction, schedule, lastRunAt } = parsed;
|
||||
const { instruction, schedule, lastRunAt, targetId } = parsed;
|
||||
|
||||
if (schedule) {
|
||||
if (isScheduledTaskDue(schedule, lastRunAt)) {
|
||||
tasks.push({ instruction, schedule, startLine, endLine });
|
||||
tasks.push({ instruction, schedule, startLine, endLine, targetId });
|
||||
}
|
||||
} else {
|
||||
// One-time task: skip if already ran
|
||||
if (!lastRunAt) {
|
||||
tasks.push({ instruction, schedule: null, startLine, endLine });
|
||||
tasks.push({ instruction, schedule: null, startLine, endLine, targetId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -339,6 +343,32 @@ function insertResultBelow(body: string, endLine: number, result: string): strin
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Replace content inside a target region identified by targetId.
|
||||
* If the target region exists, replaces its content.
|
||||
* If it doesn't exist, creates the target region below the task block,
|
||||
* wrapping any existing content between the block and the next block/heading.
|
||||
*/
|
||||
function replaceTargetRegion(body: string, targetId: string, result: string, endLine: number): string {
|
||||
const openTag = `<!--task-target:${targetId}-->`;
|
||||
const closeTag = `<!--/task-target:${targetId}-->`;
|
||||
const openIdx = body.indexOf(openTag);
|
||||
const closeIdx = body.indexOf(closeTag);
|
||||
|
||||
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
|
||||
// Target region exists — replace content between the tags
|
||||
const before = body.slice(0, openIdx + openTag.length);
|
||||
const after = body.slice(closeIdx);
|
||||
return before + '\n' + result + '\n' + after;
|
||||
}
|
||||
|
||||
// Target region doesn't exist yet — create it below the task block's closing fence
|
||||
const lines = body.split('\n');
|
||||
const taggedResult = `${openTag}\n${result}\n${closeTag}`;
|
||||
lines.splice(endLine + 1, 0, '', taggedResult);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a note has any "live" tell-rowboat tasks.
|
||||
* A task is live if:
|
||||
|
|
@ -495,7 +525,13 @@ async function processInlineTasks(): Promise<void> {
|
|||
|
||||
const result = await extractAgentResponse(run.id);
|
||||
if (result) {
|
||||
currentBody = insertResultBelow(currentBody, task.endLine, result);
|
||||
if (task.targetId) {
|
||||
// Recurring task with target region — replace content inside the region
|
||||
currentBody = replaceTargetRegion(currentBody, task.targetId, result, task.endLine);
|
||||
} else {
|
||||
// No target region — insert below the block
|
||||
currentBody = insertResultBelow(currentBody, task.endLine, result);
|
||||
}
|
||||
// Update the block JSON with lastRunAt
|
||||
const timestamp = new Date().toISOString();
|
||||
currentBody = updateBlockData(currentBody, task.startLine, task.endLine, timestamp);
|
||||
|
|
@ -531,6 +567,85 @@ async function processInlineTasks(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a @rowboat instruction via the inline task agent.
|
||||
* The agent can execute one-off tasks and/or detect scheduling intent.
|
||||
* Returns schedule info (if any), a schedule label, and optional response text.
|
||||
*/
|
||||
type ScheduleWithoutLabel =
|
||||
| { type: 'cron'; expression: string; startDate: string; endDate: string }
|
||||
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string }
|
||||
| { type: 'once'; runAt: string };
|
||||
|
||||
export async function processRowboatInstruction(
|
||||
instruction: string,
|
||||
noteContent: string,
|
||||
notePath: string,
|
||||
): Promise<{
|
||||
instruction: string;
|
||||
schedule: ScheduleWithoutLabel | null;
|
||||
scheduleLabel: string | null;
|
||||
response: string | null;
|
||||
}> {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||
|
||||
const message = [
|
||||
`Process the following @rowboat instruction from the note "${notePath}":`,
|
||||
'',
|
||||
`**Instruction:** ${instruction}`,
|
||||
'',
|
||||
'**Full note content for context:**',
|
||||
'```markdown',
|
||||
noteContent,
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
await createMessage(run.id, message);
|
||||
await waitForRunCompletion(run.id);
|
||||
|
||||
const rawResponse = await extractAgentResponse(run.id);
|
||||
if (!rawResponse) {
|
||||
return { instruction, schedule: null, scheduleLabel: null, response: null };
|
||||
}
|
||||
|
||||
// Parse out the schedule marker if present (allow multiline JSON)
|
||||
const scheduleMarkerRegex = /<!--rowboat-schedule:([\s\S]*?)-->/;
|
||||
const scheduleMatch = rawResponse.match(scheduleMarkerRegex);
|
||||
|
||||
// Parse out the instruction marker if present
|
||||
const instructionMarkerRegex = /<!--rowboat-instruction:([\s\S]*?)-->/;
|
||||
const instructionMatch = rawResponse.match(instructionMarkerRegex);
|
||||
|
||||
let schedule: ScheduleWithoutLabel | null = null;
|
||||
let scheduleLabel: string | null = null;
|
||||
let refinedInstruction = instruction;
|
||||
|
||||
if (instructionMatch) {
|
||||
refinedInstruction = instructionMatch[1].trim();
|
||||
}
|
||||
|
||||
if (scheduleMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(scheduleMatch[1]);
|
||||
if (parsed && typeof parsed === 'object' && parsed.type) {
|
||||
scheduleLabel = parsed.label || null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { label: _, ...rest } = parsed;
|
||||
schedule = rest as ScheduleWithoutLabel;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON in marker — ignore
|
||||
}
|
||||
|
||||
// Scheduled task — no response content (agent only returns markers)
|
||||
return { instruction: refinedInstruction, schedule, scheduleLabel, response: null };
|
||||
}
|
||||
|
||||
// One-time task — the full response is the content
|
||||
const response = rawResponse.trim() || null;
|
||||
return { instruction: refinedInstruction, schedule: null, scheduleLabel: null, response };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify whether an instruction contains a scheduling intent using the user's configured LLM.
|
||||
* Returns a schedule object or null for one-time tasks.
|
||||
|
|
|
|||
36
apps/x/packages/shared/src/blocks.ts
Normal file
36
apps/x/packages/shared/src/blocks.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const ImageBlockSchema = z.object({
|
||||
src: z.string(),
|
||||
alt: z.string().optional(),
|
||||
caption: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ImageBlock = z.infer<typeof ImageBlockSchema>;
|
||||
|
||||
export const EmbedBlockSchema = z.object({
|
||||
provider: z.enum(['youtube', 'figma', 'generic']),
|
||||
url: z.string().url(),
|
||||
caption: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EmbedBlock = z.infer<typeof EmbedBlockSchema>;
|
||||
|
||||
export const ChartBlockSchema = z.object({
|
||||
chart: z.enum(['line', 'bar', 'pie']),
|
||||
title: z.string().optional(),
|
||||
data: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
source: z.string().optional(),
|
||||
x: z.string(),
|
||||
y: z.string(),
|
||||
});
|
||||
|
||||
export type ChartBlock = z.infer<typeof ChartBlockSchema>;
|
||||
|
||||
export const TableBlockSchema = z.object({
|
||||
columns: z.array(z.string()),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
title: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TableBlock = z.infer<typeof TableBlockSchema>;
|
||||
|
|
@ -8,6 +8,7 @@ export * as agentSchedule from './agent-schedule.js';
|
|||
export * as agentScheduleState from './agent-schedule-state.js';
|
||||
export * as serviceEvents from './service-events.js'
|
||||
export * as inlineTask from './inline-task.js';
|
||||
export * as blocks from './blocks.js';
|
||||
export * as frontmatter from './frontmatter.js';
|
||||
export * as bases from './bases.js';
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export const InlineTaskBlockSchema = z.object({
|
|||
schedule: InlineTaskScheduleSchema.optional(),
|
||||
'schedule-label': z.string().optional(),
|
||||
lastRunAt: z.string().optional(),
|
||||
processing: z.boolean().optional(),
|
||||
targetId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type InlineTaskBlock = z.infer<typeof InlineTaskBlockSchema>;
|
||||
|
|
|
|||
|
|
@ -522,6 +522,23 @@ const ipcSchemas = {
|
|||
]).nullable(),
|
||||
}),
|
||||
},
|
||||
'inline-task:process': {
|
||||
req: z.object({
|
||||
instruction: z.string(),
|
||||
noteContent: z.string(),
|
||||
notePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
instruction: z.string(),
|
||||
schedule: z.union([
|
||||
z.object({ type: z.literal('cron'), expression: z.string(), startDate: z.string(), endDate: z.string() }),
|
||||
z.object({ type: z.literal('window'), cron: z.string(), startTime: z.string(), endTime: z.string(), startDate: z.string(), endDate: z.string() }),
|
||||
z.object({ type: z.literal('once'), runAt: z.string() }),
|
||||
]).nullable(),
|
||||
scheduleLabel: z.string().nullable(),
|
||||
response: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
// Billing channels
|
||||
'billing:getInfo': {
|
||||
req: z.null(),
|
||||
|
|
|
|||
144
apps/x/pnpm-lock.yaml
generated
144
apps/x/pnpm-lock.yaml
generated
|
|
@ -238,6 +238,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.3(react@19.2.3)
|
||||
recharts:
|
||||
specifier: ^3.8.0
|
||||
version: 3.8.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
|
@ -2547,6 +2550,17 @@ packages:
|
|||
'@react-pdf/types@2.9.2':
|
||||
resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==}
|
||||
|
||||
'@reduxjs/toolkit@2.11.2':
|
||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@remirror/core-constants@3.0.0':
|
||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||
|
||||
|
|
@ -2907,6 +2921,9 @@ packages:
|
|||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@swc/helpers@0.5.18':
|
||||
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
|
||||
|
||||
|
|
@ -4270,6 +4287,9 @@ packages:
|
|||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
||||
|
||||
|
|
@ -4500,6 +4520,9 @@ packages:
|
|||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.45.1:
|
||||
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
|
||||
|
||||
es6-error@4.1.1:
|
||||
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
|
||||
|
||||
|
|
@ -5178,6 +5201,12 @@ packages:
|
|||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.1.4:
|
||||
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -6565,6 +6594,18 @@ packages:
|
|||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-redux@9.2.0:
|
||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-refresh@0.18.0:
|
||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -6630,10 +6671,26 @@ packages:
|
|||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
recharts@3.8.0:
|
||||
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
rechoir@0.8.0:
|
||||
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
|
|
@ -6706,6 +6763,9 @@ packages:
|
|||
resolution: {integrity: sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==}
|
||||
engines: {node: '>=14', npm: '>=7'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve-alpn@1.2.1:
|
||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||
|
||||
|
|
@ -7114,6 +7174,9 @@ packages:
|
|||
tiny-inflate@1.0.3:
|
||||
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinyexec@1.0.2:
|
||||
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -7385,6 +7448,9 @@ packages:
|
|||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
virtual-dom@2.1.1:
|
||||
resolution: {integrity: sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==}
|
||||
|
||||
|
|
@ -10395,6 +10461,18 @@ snapshots:
|
|||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/stylesheet': 6.1.2
|
||||
|
||||
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.1.4
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
|
||||
|
||||
'@remirror/core-constants@3.0.0': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
||||
|
|
@ -10840,6 +10918,8 @@ snapshots:
|
|||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@swc/helpers@0.5.18':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -12342,6 +12422,8 @@ snapshots:
|
|||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
|
|
@ -12625,6 +12707,8 @@ snapshots:
|
|||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
es-toolkit@1.45.1: {}
|
||||
|
||||
es6-error@4.1.1:
|
||||
optional: true
|
||||
|
||||
|
|
@ -13601,6 +13685,10 @@ snapshots:
|
|||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.4: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
|
|
@ -15270,6 +15358,15 @@ snapshots:
|
|||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.2.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
redux: 5.0.1
|
||||
|
||||
react-refresh@0.18.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3):
|
||||
|
|
@ -15344,10 +15441,36 @@ snapshots:
|
|||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
recharts@3.8.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.45.1
|
||||
eventemitter3: 5.0.1
|
||||
immer: 10.2.0
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-is: 16.13.1
|
||||
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
rechoir@0.8.0:
|
||||
dependencies:
|
||||
resolve: 1.22.11
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
|
@ -15454,6 +15577,8 @@ snapshots:
|
|||
dependencies:
|
||||
pe-library: 1.0.1
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-alpn@1.2.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
|
@ -15947,6 +16072,8 @@ snapshots:
|
|||
|
||||
tiny-inflate@1.0.3: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
|
|
@ -16218,6 +16345,23 @@ snapshots:
|
|||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.7
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
virtual-dom@2.1.1:
|
||||
dependencies:
|
||||
browser-split: 0.0.1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue