mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
tasks execute immediately
This commit is contained in:
parent
91030a5fca
commit
d291ceec80
8 changed files with 245 additions and 24 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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ interface MarkdownEditorProps {
|
|||
frontmatter?: string | null
|
||||
onFrontmatterChange?: (raw: string | null) => void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
notePath?: string
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -323,6 +324,7 @@ export function MarkdownEditor({
|
|||
frontmatter,
|
||||
onFrontmatterChange,
|
||||
onExport,
|
||||
notePath,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -928,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 block with processing state immediately
|
||||
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' },
|
||||
|
|
@ -953,17 +948,109 @@ 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)
|
||||
|
||||
// Find the taskBlock we just inserted so we can update it later
|
||||
const processingData = JSON.stringify(blockData)
|
||||
let taskBlockPos: number | null = null
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (taskBlockPos !== null) return false
|
||||
if (node.type.name === 'taskBlock' && node.attrs.data === processingData) {
|
||||
taskBlockPos = pos
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Get editor content for the agent
|
||||
const editorContent = editor.storage.markdown?.getMarkdown?.() ?? ''
|
||||
|
||||
// Call the assistant agent asynchronously
|
||||
try {
|
||||
const result = await window.ipc.invoke('inline-task:process', {
|
||||
instruction,
|
||||
noteContent: editorContent,
|
||||
notePath: notePath ?? '',
|
||||
})
|
||||
|
||||
// Update the block: remove processing, add schedule if any
|
||||
const updatedData: Record<string, unknown> = { instruction }
|
||||
if (result.schedule) {
|
||||
updatedData.schedule = result.schedule
|
||||
updatedData['schedule-label'] = result.scheduleLabel
|
||||
|
||||
// Mark note as live if there's a schedule
|
||||
if (onFrontmatterChange) {
|
||||
const fields = extractAllFrontmatterValues(frontmatter ?? null)
|
||||
fields['live_note'] = 'true'
|
||||
onFrontmatterChange(buildFrontmatter(fields))
|
||||
}
|
||||
}
|
||||
|
||||
// Find the processing block again (position may have shifted)
|
||||
let currentPos: number | null = null
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (currentPos !== null) return false
|
||||
if (node.type.name === 'taskBlock') {
|
||||
try {
|
||||
const data = JSON.parse(node.attrs.data || '{}')
|
||||
if (data.instruction === instruction && data.processing === true) {
|
||||
currentPos = pos
|
||||
return false
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
})
|
||||
|
||||
if (currentPos !== null) {
|
||||
const node = editor.state.doc.nodeAt(currentPos)
|
||||
if (node) {
|
||||
let tr = editor.state.tr.setNodeMarkup(currentPos, undefined, {
|
||||
data: JSON.stringify(updatedData),
|
||||
})
|
||||
|
||||
// Insert response text below the block if present
|
||||
if (result.response) {
|
||||
const insertPos = currentPos + node.nodeSize
|
||||
const responseNode = editor.schema.nodes.paragraph?.create(
|
||||
null,
|
||||
editor.schema.text(result.response),
|
||||
)
|
||||
if (responseNode) {
|
||||
tr = tr.insert(insertPos, responseNode)
|
||||
}
|
||||
}
|
||||
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RowboatAdd] Processing failed:', error)
|
||||
|
||||
// Remove processing state on error
|
||||
let currentPos: number | null = null
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (currentPos !== null) return false
|
||||
if (node.type.name === 'taskBlock') {
|
||||
try {
|
||||
const data = JSON.parse(node.attrs.data || '{}')
|
||||
if (data.instruction === instruction && data.processing === true) {
|
||||
currentPos = pos
|
||||
return false
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
})
|
||||
|
||||
if (currentPos !== null) {
|
||||
const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, {
|
||||
data: JSON.stringify({ instruction }),
|
||||
})
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange])
|
||||
}, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange, notePath])
|
||||
|
||||
const handleRowboatRemove = useCallback(() => {
|
||||
if (!editor || !rowboatBlockEdit) return
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
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 TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let instruction = ''
|
||||
let scheduleLabel = ''
|
||||
let processing = false
|
||||
|
||||
try {
|
||||
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
|
||||
instruction = parsed.instruction
|
||||
scheduleLabel = parsed['schedule-label'] ?? ''
|
||||
processing = parsed.processing ?? false
|
||||
} catch {
|
||||
// Fallback: show raw data
|
||||
instruction = raw
|
||||
|
|
@ -29,7 +31,13 @@ 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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -23,5 +30,31 @@ You are an inline task execution agent. You receive a @rowboat instruction from
|
|||
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.
|
||||
|
||||
# Schedule Classification
|
||||
|
||||
If the instruction implies a recurring or future-scheduled task, you MUST include a schedule marker in your response using this exact format:
|
||||
|
||||
<!--rowboat-schedule:{"type":"...","label":"..."}-->
|
||||
|
||||
Place this marker at the very beginning of your response, on its own line, before any other content.
|
||||
|
||||
Schedule types:
|
||||
1. "cron" — recurring schedule: <!--rowboat-schedule:{"type":"cron","expression":"<5-field cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}-->
|
||||
"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 a time window: <!--rowboat-schedule:{"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}-->
|
||||
|
||||
3. "once" — run once at a specific future time: <!--rowboat-schedule:{"type":"once","runAt":"<ISO 8601>","label":"<human readable>"}-->
|
||||
|
||||
The "label" field must be a short plain-English description starting with "runs" (e.g. "runs every 2 minutes until Mar 12", "runs daily at 8 AM until Mar 12", "runs once on Mar 20 at 3 PM").
|
||||
|
||||
Current local time: ${localNow}
|
||||
Timezone: ${tz}
|
||||
Current UTC time: ${nowISO}
|
||||
|
||||
If the instruction is a one-time immediate task with no scheduling intent, do NOT include the schedule marker. Just execute and return the result.
|
||||
If the instruction has BOTH scheduling intent AND something to execute immediately, include the schedule marker AND your response content.
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -531,6 +531,78 @@ 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.
|
||||
*/
|
||||
export async function processRowboatInstruction(
|
||||
instruction: string,
|
||||
noteContent: string,
|
||||
notePath: string,
|
||||
): Promise<{
|
||||
schedule: { type: 'cron'; expression: string; startDate: string; endDate: string }
|
||||
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string }
|
||||
| { type: 'once'; runAt: string }
|
||||
| null;
|
||||
scheduleLabel: string | null;
|
||||
response: string | null;
|
||||
}> {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||
|
||||
const message = [
|
||||
`Execute the following 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 { schedule: null, scheduleLabel: null, response: null };
|
||||
}
|
||||
|
||||
// Parse out the schedule marker if present
|
||||
const scheduleMarkerRegex = /<!--rowboat-schedule:(.*?)-->/;
|
||||
const match = rawResponse.match(scheduleMarkerRegex);
|
||||
|
||||
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 };
|
||||
|
||||
let schedule: ScheduleWithoutLabel | null = null;
|
||||
let scheduleLabel: string | null = null;
|
||||
let response: string | null = null;
|
||||
|
||||
if (match) {
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]);
|
||||
if (parsed && typeof parsed === 'object' && parsed.type) {
|
||||
scheduleLabel = parsed.label || null;
|
||||
const { label: _, ...rest } = parsed;
|
||||
schedule = rest as ScheduleWithoutLabel;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON in marker — ignore
|
||||
}
|
||||
// Remove the marker from the response text
|
||||
const cleaned = rawResponse.replace(scheduleMarkerRegex, '').trim();
|
||||
response = cleaned || null;
|
||||
} else {
|
||||
response = rawResponse.trim() || null;
|
||||
}
|
||||
|
||||
return { schedule, scheduleLabel, 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.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const InlineTaskBlockSchema = z.object({
|
|||
schedule: InlineTaskScheduleSchema.optional(),
|
||||
'schedule-label': z.string().optional(),
|
||||
lastRunAt: z.string().optional(),
|
||||
processing: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type InlineTaskBlock = z.infer<typeof InlineTaskBlockSchema>;
|
||||
|
|
|
|||
|
|
@ -522,6 +522,22 @@ const ipcSchemas = {
|
|||
]).nullable(),
|
||||
}),
|
||||
},
|
||||
'inline-task:process': {
|
||||
req: z.object({
|
||||
instruction: z.string(),
|
||||
noteContent: z.string(),
|
||||
notePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue