|
| 1 | +import type { |
| 2 | + AgentStore, |
| 3 | + ChatBubble, |
| 4 | + ChatMessage, |
| 5 | + MessageEngineContext, |
| 6 | + PageContentContext, |
| 7 | + PreparedMessages, |
| 8 | + ToolCall, |
| 9 | +} from '@haklex/rich-agent-core' |
| 10 | +import type { SerializedEditorState } from 'lexical' |
| 11 | + |
| 12 | +import { |
| 13 | + BaseLastUserContentProvider, |
| 14 | + BaseSystemRoleProvider, |
| 15 | + BaseSystemRootProvider, |
| 16 | + buildDocumentContext, |
| 17 | + MessagesEngine, |
| 18 | +} from '@haklex/rich-agent-core' |
| 19 | +import { defaultAgentSystemMessage } from '@haklex/rich-ext-ai-agent' |
| 20 | + |
| 21 | +const DOCUMENT_TOOL_SYSTEM_ROLE = `Use the document editing tools according to the following contract. |
| 22 | +
|
| 23 | +## Document XML Contract |
| 24 | +
|
| 25 | +- Document XML references use the serialized \`<doc>...</doc>\` structure. |
| 26 | +- Tool \`xml\` arguments must contain block fragments only, not a full \`<document>\` wrapper. |
| 27 | +- Use node IDs from injected XML context when a tool requires a target block. |
| 28 | +
|
| 29 | +## Tool Contract |
| 30 | +
|
| 31 | +### \`insert_node\` |
| 32 | +
|
| 33 | +- Insert one or more block nodes. |
| 34 | +- \`position.type\` must be \`before\`, \`after\`, or \`root\`. |
| 35 | +- \`position.blockId\` is required for \`before\` and \`after\`. |
| 36 | +- \`xml\` must be valid block XML fragments. |
| 37 | +
|
| 38 | +### \`replace_node\` |
| 39 | +
|
| 40 | +- Replace the block identified by \`blockId\`. |
| 41 | +- The first block in \`xml\` replaces the target block. |
| 42 | +- Additional blocks, if any, are inserted after the replaced block. |
| 43 | +- Do not invent a new block ID inside replacement XML. |
| 44 | +
|
| 45 | +### \`delete_node\` |
| 46 | +
|
| 47 | +- Delete the block identified by \`blockId\`. |
| 48 | +- Use only when the user requests removal or the edit clearly requires deleting superseded content. |
| 49 | +
|
| 50 | +### \`search_document\` |
| 51 | +
|
| 52 | +- Use to locate candidate blocks by text or block type. |
| 53 | +- Prefer search when the target block is unknown or a prior edit attempt failed. |
| 54 | +
|
| 55 | +## Failure Recovery |
| 56 | +
|
| 57 | +- \`block_not_found\`: search again and retry with the correct target. |
| 58 | +- \`block_modified\`: assume the reference is stale; re-locate the target or narrow the edit. |
| 59 | +- \`xml_parse_error\`, \`invalid_xml\`, \`empty_xml\`: rewrite the XML as valid block fragments and retry.` |
| 60 | + |
| 61 | +class SystemRoleInjector extends BaseSystemRootProvider { |
| 62 | + protected buildMessages() { |
| 63 | + return [defaultAgentSystemMessage] |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +class ToolSystemRoleInjector extends BaseSystemRoleProvider { |
| 68 | + protected buildContent() { |
| 69 | + return DOCUMENT_TOOL_SYSTEM_ROLE |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +class PageEditorContextInjector extends BaseLastUserContentProvider { |
| 74 | + protected buildContent(context: MessageEngineContext) { |
| 75 | + const pageContext = resolvePageContentContext(context) |
| 76 | + if (!pageContext) return null |
| 77 | + |
| 78 | + const formatted = formatPageContentContext(pageContext) |
| 79 | + if (!formatted) return null |
| 80 | + |
| 81 | + return { content: formatted, contextType: 'current_page_context' } |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +function resolvePageContentContext( |
| 86 | + context: MessageEngineContext, |
| 87 | +): PageContentContext | undefined { |
| 88 | + if (context.pageContentContext) return context.pageContentContext |
| 89 | + |
| 90 | + const initialPageEditor = context.initialContext?.pageEditor |
| 91 | + if (!initialPageEditor) return undefined |
| 92 | + |
| 93 | + return { |
| 94 | + markdown: initialPageEditor.markdown, |
| 95 | + metadata: initialPageEditor.metadata, |
| 96 | + xml: context.stepContext?.stepPageEditor?.xml || initialPageEditor.xml, |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +function formatPageContentContext(context: PageContentContext): string { |
| 101 | + const sections: string[] = [] |
| 102 | + |
| 103 | + if (context.markdown) { |
| 104 | + const charCount = context.metadata.charCount ?? context.markdown.length |
| 105 | + const lineCount = |
| 106 | + context.metadata.lineCount ?? context.markdown.split('\n').length |
| 107 | + sections.push( |
| 108 | + `<markdown chars="${charCount}" lines="${lineCount}">\n${context.markdown}\n</markdown>`, |
| 109 | + ) |
| 110 | + } |
| 111 | + |
| 112 | + if (context.xml) { |
| 113 | + sections.push( |
| 114 | + `<doc_xml_structure>\n<instruction>IMPORTANT: Use node IDs from this XML structure when performing modify or remove operations.</instruction>\n${context.xml}\n</doc_xml_structure>`, |
| 115 | + ) |
| 116 | + } |
| 117 | + |
| 118 | + return `<current_page title="${context.metadata.title}">\n${sections.join('\n')}\n</current_page>` |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * Converts store ChatBubble[] to LLM ChatMessage[] for conversation history. |
| 123 | + * Skips UI-only bubbles (thinking, error, diff_summary, diff_review). |
| 124 | + */ |
| 125 | +export function bubblesToChatMessages(bubbles: ChatBubble[]): ChatMessage[] { |
| 126 | + const messages: ChatMessage[] = [] |
| 127 | + |
| 128 | + for (const bubble of bubbles) { |
| 129 | + if (bubble.type === 'user') { |
| 130 | + messages.push({ role: 'user', content: bubble.content }) |
| 131 | + } else if ( |
| 132 | + bubble.type === 'assistant' && |
| 133 | + bubble.content && |
| 134 | + !bubble.streaming |
| 135 | + ) { |
| 136 | + messages.push({ role: 'assistant', content: bubble.content }) |
| 137 | + } else if (bubble.type === 'tool_call_group') { |
| 138 | + const toolCalls: ToolCall[] = [] |
| 139 | + for (const item of bubble.items) { |
| 140 | + toolCalls.push({ |
| 141 | + id: item.id, |
| 142 | + name: item.toolName, |
| 143 | + arguments: JSON.stringify(item.params), |
| 144 | + }) |
| 145 | + } |
| 146 | + |
| 147 | + if (toolCalls.length > 0) { |
| 148 | + messages.push({ role: 'assistant_tool_call', toolCalls }) |
| 149 | + for (const item of bubble.items) { |
| 150 | + if (item.status === 'completed' || item.status === 'error') { |
| 151 | + messages.push({ |
| 152 | + role: 'tool_result', |
| 153 | + toolCallId: item.id, |
| 154 | + content: item.result || item.error || '', |
| 155 | + isError: item.status === 'error', |
| 156 | + }) |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + return messages |
| 164 | +} |
| 165 | + |
| 166 | +/** |
| 167 | + * Creates a message engine that includes conversation history from the |
| 168 | + * AgentStore when building messages for the LLM. This is a duck-typed |
| 169 | + * replacement for AgentMessagesEngine with multi-turn support. |
| 170 | + * |
| 171 | + * The engine replicates the same processor pipeline as the library's |
| 172 | + * AgentMessagesEngine (system role, tool docs, page context injection) |
| 173 | + * but prepends conversation history from bubbles before the current |
| 174 | + * user message. |
| 175 | + */ |
| 176 | +export function createContextAwareEngine(store: AgentStore) { |
| 177 | + const engine = new MessagesEngine([ |
| 178 | + new SystemRoleInjector(), |
| 179 | + new ToolSystemRoleInjector(), |
| 180 | + new PageEditorContextInjector(), |
| 181 | + ]) |
| 182 | + |
| 183 | + return { |
| 184 | + processWithEditor(params: { |
| 185 | + editorState: SerializedEditorState |
| 186 | + userInput: string |
| 187 | + title?: string |
| 188 | + }): PreparedMessages { |
| 189 | + const bubbles = store.getState().bubbles |
| 190 | + |
| 191 | + // AgentChatPanel adds the user bubble to the store synchronously |
| 192 | + // before emitting 'send', so the last bubble may be the current |
| 193 | + // user message. Exclude it to avoid duplication (the engine creates |
| 194 | + // its own user message below). |
| 195 | + const lastBubble = bubbles[bubbles.length - 1] |
| 196 | + const historyBubbles = |
| 197 | + lastBubble?.type === 'user' && lastBubble.content === params.userInput |
| 198 | + ? bubbles.slice(0, -1) |
| 199 | + : bubbles |
| 200 | + |
| 201 | + const history = bubblesToChatMessages(historyBubbles) |
| 202 | + |
| 203 | + const userMessage: Extract<ChatMessage, { role: 'user' }> = { |
| 204 | + role: 'user', |
| 205 | + content: params.userInput, |
| 206 | + cacheBreakpoint: true, |
| 207 | + } |
| 208 | + |
| 209 | + return engine.process({ |
| 210 | + messages: [...history, userMessage], |
| 211 | + pageContentContext: { |
| 212 | + metadata: { title: params.title ?? 'Current Document' }, |
| 213 | + xml: buildDocumentContext(params.editorState, { |
| 214 | + mode: 'full', |
| 215 | + compact: true, |
| 216 | + }), |
| 217 | + }, |
| 218 | + }) |
| 219 | + }, |
| 220 | + } |
| 221 | +} |
0 commit comments