mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(agent): add extensible tag interception system and inline reactions
Refactor the attachment tag extraction into a generic TagResolver/StreamTagExtractor system that supports multiple custom tags. Implement <reactions> tag allowing the agent to embed emoji reactions directly in text responses, dispatched as side-effects through the channel reactor interface. - Add TagResolver interface and StreamTagExtractor streaming state machine - Refactor AttachmentsStreamExtractor as backward-compatible wrapper - Add reactionsResolver and ReactionDeltaAction stream event - Wire reaction dispatch in Go channel inbound processor - Fix .gitignore to scope compiled binary patterns to repo root
This commit is contained in:
+2
-2
@@ -91,8 +91,8 @@ Thumbs.db
|
|||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
# compiled files
|
# compiled files
|
||||||
memoh
|
/memoh
|
||||||
agent
|
/agent
|
||||||
|
|
||||||
docs/docs/.vitepress/cache
|
docs/docs/.vitepress/cache
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface StreamEvent {
|
|||||||
| 'text_start' | 'text_delta' | 'text_end'
|
| 'text_start' | 'text_delta' | 'text_end'
|
||||||
| 'reasoning_start' | 'reasoning_delta' | 'reasoning_end'
|
| 'reasoning_start' | 'reasoning_delta' | 'reasoning_end'
|
||||||
| 'tool_call_start' | 'tool_call_end'
|
| 'tool_call_start' | 'tool_call_end'
|
||||||
| 'attachment_delta'
|
| 'attachment_delta' | 'reaction_delta'
|
||||||
| 'agent_start' | 'agent_end' | 'agent_abort'
|
| 'agent_start' | 'agent_end' | 'agent_abort'
|
||||||
| 'processing_started' | 'processing_completed' | 'processing_failed'
|
| 'processing_started' | 'processing_completed' | 'processing_failed'
|
||||||
| 'error'
|
| 'error'
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ func provideChannelManager(log *slog.Logger, registry *channel.Registry, channel
|
|||||||
if mw := channelRouter.IdentityMiddleware(); mw != nil {
|
if mw := channelRouter.IdentityMiddleware(); mw != nil {
|
||||||
mgr.Use(mw)
|
mgr.Use(mw)
|
||||||
}
|
}
|
||||||
|
channelRouter.SetReactor(mgr)
|
||||||
return mgr
|
return mgr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ type RouteResolver interface {
|
|||||||
ResolveConversation(ctx context.Context, input route.ResolveInput) (route.ResolveConversationResult, error)
|
ResolveConversation(ctx context.Context, input route.ResolveInput) (route.ResolveConversationResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type channelReactor interface {
|
||||||
|
React(ctx context.Context, botID string, channelType channel.ChannelType, req channel.ReactRequest) error
|
||||||
|
}
|
||||||
|
|
||||||
type mediaIngestor interface {
|
type mediaIngestor interface {
|
||||||
Ingest(ctx context.Context, input media.IngestInput) (media.Asset, error)
|
Ingest(ctx context.Context, input media.IngestInput) (media.Asset, error)
|
||||||
// GetByStorageKey resolves an asset by reading its sidecar JSON.
|
// GetByStorageKey resolves an asset by reading its sidecar JSON.
|
||||||
@@ -55,6 +59,7 @@ type ChannelInboundProcessor struct {
|
|||||||
routeResolver RouteResolver
|
routeResolver RouteResolver
|
||||||
message messagepkg.Writer
|
message messagepkg.Writer
|
||||||
mediaService mediaIngestor
|
mediaService mediaIngestor
|
||||||
|
reactor channelReactor
|
||||||
inboxService *inbox.Service
|
inboxService *inbox.Service
|
||||||
registry *channel.Registry
|
registry *channel.Registry
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
@@ -114,6 +119,14 @@ func (p *ChannelInboundProcessor) SetMediaService(mediaService mediaIngestor) {
|
|||||||
p.mediaService = mediaService
|
p.mediaService = mediaService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetReactor configures the channel reactor for handling inline emoji reactions.
|
||||||
|
func (p *ChannelInboundProcessor) SetReactor(reactor channelReactor) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.reactor = reactor
|
||||||
|
}
|
||||||
|
|
||||||
// SetStreamObserver configures an observer that receives copies of all stream
|
// SetStreamObserver configures an observer that receives copies of all stream
|
||||||
// events produced for non-local channels (e.g. Telegram, Feishu). This enables
|
// events produced for non-local channels (e.g. Telegram, Feishu). This enables
|
||||||
// cross-channel visibility in the WebUI without coupling adapters to the hub.
|
// cross-channel visibility in the WebUI without coupling adapters to the hub.
|
||||||
@@ -443,6 +456,10 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel
|
|||||||
}
|
}
|
||||||
assetMu.Unlock()
|
assetMu.Unlock()
|
||||||
}
|
}
|
||||||
|
if event.Type == channel.StreamEventReaction && len(event.Reactions) > 0 {
|
||||||
|
p.dispatchReactions(ctx, identity.BotID, msg.Channel, target, sourceMessageID, event.Reactions)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if pushErr := stream.Push(ctx, events[i]); pushErr != nil {
|
if pushErr := stream.Push(ctx, events[i]); pushErr != nil {
|
||||||
streamErr = pushErr
|
streamErr = pushErr
|
||||||
break
|
break
|
||||||
@@ -861,6 +878,7 @@ type gatewayStreamEnvelope struct {
|
|||||||
Input json.RawMessage `json:"input"`
|
Input json.RawMessage `json:"input"`
|
||||||
Result json.RawMessage `json:"result"`
|
Result json.RawMessage `json:"result"`
|
||||||
Attachments json.RawMessage `json:"attachments"`
|
Attachments json.RawMessage `json:"attachments"`
|
||||||
|
Reactions json.RawMessage `json:"reactions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type gatewayStreamDoneData struct {
|
type gatewayStreamDoneData struct {
|
||||||
@@ -954,6 +972,14 @@ func mapStreamChunkToChannelEvents(chunk conversation.StreamChunk) ([]channel.St
|
|||||||
return []channel.StreamEvent{
|
return []channel.StreamEvent{
|
||||||
{Type: channel.StreamEventAttachment, Attachments: attachments},
|
{Type: channel.StreamEventAttachment, Attachments: attachments},
|
||||||
}, finalMessages, nil
|
}, finalMessages, nil
|
||||||
|
case "reaction_delta":
|
||||||
|
reactions := parseReactionDelta(envelope.Reactions)
|
||||||
|
if len(reactions) == 0 {
|
||||||
|
return nil, finalMessages, nil
|
||||||
|
}
|
||||||
|
return []channel.StreamEvent{
|
||||||
|
{Type: channel.StreamEventReaction, Reactions: reactions},
|
||||||
|
}, finalMessages, nil
|
||||||
case "agent_start":
|
case "agent_start":
|
||||||
return []channel.StreamEvent{
|
return []channel.StreamEvent{
|
||||||
{
|
{
|
||||||
@@ -1935,6 +1961,73 @@ func parseAttachmentDelta(raw json.RawMessage) []channel.Attachment {
|
|||||||
return attachments
|
return attachments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseReactionDelta converts raw JSON reaction data to channel ReactRequests.
|
||||||
|
func parseReactionDelta(raw json.RawMessage) []channel.ReactRequest {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var items []struct {
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &items); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
reactions := make([]channel.ReactRequest, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
emoji := strings.TrimSpace(item.Emoji)
|
||||||
|
if emoji == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reactions = append(reactions, channel.ReactRequest{
|
||||||
|
Emoji: emoji,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return reactions
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchReactions sends emoji reactions to the channel for the source message.
|
||||||
|
func (p *ChannelInboundProcessor) dispatchReactions(
|
||||||
|
ctx context.Context,
|
||||||
|
botID string,
|
||||||
|
channelType channel.ChannelType,
|
||||||
|
target string,
|
||||||
|
sourceMessageID string,
|
||||||
|
reactions []channel.ReactRequest,
|
||||||
|
) {
|
||||||
|
if p.reactor == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target = strings.TrimSpace(target)
|
||||||
|
sourceMessageID = strings.TrimSpace(sourceMessageID)
|
||||||
|
if target == "" || sourceMessageID == "" {
|
||||||
|
if p.logger != nil {
|
||||||
|
p.logger.Warn("cannot dispatch reactions: missing target or source message ID",
|
||||||
|
slog.String("bot_id", botID),
|
||||||
|
slog.String("channel", channelType.String()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, reaction := range reactions {
|
||||||
|
req := channel.ReactRequest{
|
||||||
|
Target: target,
|
||||||
|
MessageID: sourceMessageID,
|
||||||
|
Emoji: reaction.Emoji,
|
||||||
|
}
|
||||||
|
if err := p.reactor.React(ctx, strings.TrimSpace(botID), channelType, req); err != nil {
|
||||||
|
if p.logger != nil {
|
||||||
|
p.logger.Warn("inline reaction failed",
|
||||||
|
slog.String("bot_id", botID),
|
||||||
|
slog.String("channel", channelType.String()),
|
||||||
|
slog.String("emoji", reaction.Emoji),
|
||||||
|
slog.String("message_id", sourceMessageID),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// buildRouteMetadata extracts user/conversation information for route metadata persistence.
|
// buildRouteMetadata extracts user/conversation information for route metadata persistence.
|
||||||
func buildRouteMetadata(msg channel.InboundMessage, identity InboundIdentity) map[string]any {
|
func buildRouteMetadata(msg channel.InboundMessage, identity InboundIdentity) map[string]any {
|
||||||
m := make(map[string]any)
|
m := make(map[string]any)
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const (
|
|||||||
StreamEventAttachment StreamEventType = "attachment"
|
StreamEventAttachment StreamEventType = "attachment"
|
||||||
StreamEventAgentStart StreamEventType = "agent_start"
|
StreamEventAgentStart StreamEventType = "agent_start"
|
||||||
StreamEventAgentEnd StreamEventType = "agent_end"
|
StreamEventAgentEnd StreamEventType = "agent_end"
|
||||||
|
StreamEventReaction StreamEventType = "reaction"
|
||||||
StreamEventProcessingStarted StreamEventType = "processing_started"
|
StreamEventProcessingStarted StreamEventType = "processing_started"
|
||||||
StreamEventProcessingCompleted StreamEventType = "processing_completed"
|
StreamEventProcessingCompleted StreamEventType = "processing_completed"
|
||||||
StreamEventProcessingFailed StreamEventType = "processing_failed"
|
StreamEventProcessingFailed StreamEventType = "processing_failed"
|
||||||
@@ -146,6 +147,7 @@ type StreamEvent struct {
|
|||||||
ToolCall *StreamToolCall `json:"tool_call,omitempty"`
|
ToolCall *StreamToolCall `json:"tool_call,omitempty"`
|
||||||
Phase StreamPhase `json:"phase,omitempty"`
|
Phase StreamPhase `json:"phase,omitempty"`
|
||||||
Attachments []Attachment `json:"attachments,omitempty"`
|
Attachments []Attachment `json:"attachments,omitempty"`
|
||||||
|
Reactions []ReactRequest `json:"reactions,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-31
@@ -25,11 +25,13 @@ import { system, schedule, heartbeat, subagentSystem } from './prompts'
|
|||||||
import { AuthFetcher } from './types'
|
import { AuthFetcher } from './types'
|
||||||
import { createModel } from './model'
|
import { createModel } from './model'
|
||||||
import {
|
import {
|
||||||
extractAttachmentsFromText,
|
|
||||||
stripAttachmentsFromMessages,
|
stripAttachmentsFromMessages,
|
||||||
dedupeAttachments,
|
dedupeAttachments,
|
||||||
AttachmentsStreamExtractor,
|
attachmentsResolver,
|
||||||
} from './utils/attachments'
|
} from './utils/attachments'
|
||||||
|
import type { ContainerFileAttachment } from './types/attachment'
|
||||||
|
import { reactionsResolver, type ReactionItem } from './utils/reactions'
|
||||||
|
import { StreamTagExtractor, extractTagsFromText, type TagEvent } from './utils/tag-extractor'
|
||||||
import { createImagePartFromAttachment } from './utils/image-parts'
|
import { createImagePartFromAttachment } from './utils/image-parts'
|
||||||
import type { GatewayInputAttachment } from './types/attachment'
|
import type { GatewayInputAttachment } from './types/attachment'
|
||||||
import { getMCPTools } from './tools/mcp'
|
import { getMCPTools } from './tools/mcp'
|
||||||
@@ -330,10 +332,16 @@ export const createAgent = (
|
|||||||
basePrepareStep: () => ({ system: systemPrompt }),
|
basePrepareStep: () => ({ system: systemPrompt }),
|
||||||
})
|
})
|
||||||
const stepUsages = buildStepUsages(steps)
|
const stepUsages = buildStepUsages(steps)
|
||||||
const { cleanedText, attachments: textAttachments } =
|
const tagResolvers = [attachmentsResolver, reactionsResolver]
|
||||||
extractAttachmentsFromText(text)
|
const { cleanedText, events } = extractTagsFromText(text, tagResolvers)
|
||||||
|
const textAttachments = events
|
||||||
|
.filter((e) => e.tag === 'attachments')
|
||||||
|
.flatMap((e) => e.data as ContainerFileAttachment[])
|
||||||
|
const reactions = events
|
||||||
|
.filter((e) => e.tag === 'reactions')
|
||||||
|
.flatMap((e) => e.data as ReactionItem[])
|
||||||
const { messages: strippedMessages, attachments: messageAttachments } =
|
const { messages: strippedMessages, attachments: messageAttachments } =
|
||||||
stripAttachmentsFromMessages(response.messages)
|
stripAttachmentsFromMessages(response.messages, [reactionsResolver])
|
||||||
const allAttachments = dedupeAttachments([
|
const allAttachments = dedupeAttachments([
|
||||||
...textAttachments,
|
...textAttachments,
|
||||||
...messageAttachments,
|
...messageAttachments,
|
||||||
@@ -348,6 +356,7 @@ export const createAgent = (
|
|||||||
usage,
|
usage,
|
||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
attachments: allAttachments,
|
attachments: allAttachments,
|
||||||
|
reactions,
|
||||||
skills: getEnabledSkills(),
|
skills: getEnabledSkills(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,12 +481,34 @@ export const createAgent = (
|
|||||||
return 'Model stream failed'
|
return 'Model stream failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function* emitTagEvents(events: TagEvent[]): Generator<AgentStreamAction> {
|
||||||
|
for (const event of events) {
|
||||||
|
switch (event.tag) {
|
||||||
|
case 'attachments': {
|
||||||
|
const attachments = dedupeAttachments(event.data as ContainerFileAttachment[]) as ContainerFileAttachment[]
|
||||||
|
if (attachments.length) {
|
||||||
|
yield { type: 'attachment_delta', attachments }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'reactions': {
|
||||||
|
const reactions = event.data as ReactionItem[]
|
||||||
|
if (reactions.length) {
|
||||||
|
yield { type: 'reaction_delta', reactions }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function* stream(input: AgentInput): AsyncGenerator<AgentStreamAction> {
|
async function* stream(input: AgentInput): AsyncGenerator<AgentStreamAction> {
|
||||||
const userPrompt = generateUserPrompt(input)
|
const userPrompt = generateUserPrompt(input)
|
||||||
const messages = [...input.messages, userPrompt]
|
const messages = [...input.messages, userPrompt]
|
||||||
input.skills.forEach((skill) => enableSkill(skill))
|
input.skills.forEach((skill) => enableSkill(skill))
|
||||||
const systemPrompt = await generateSystemPrompt()
|
const systemPrompt = await generateSystemPrompt()
|
||||||
const attachmentsExtractor = new AttachmentsStreamExtractor()
|
const tagResolvers = [attachmentsResolver, reactionsResolver]
|
||||||
|
const tagExtractor = new StreamTagExtractor(tagResolvers)
|
||||||
const textLoopGuard = loopDetectionEnabled
|
const textLoopGuard = loopDetectionEnabled
|
||||||
? createTextLoopGuard({
|
? createTextLoopGuard({
|
||||||
consecutiveHitsToAbort: LOOP_DETECTED_STREAK_THRESHOLD,
|
consecutiveHitsToAbort: LOOP_DETECTED_STREAK_THRESHOLD,
|
||||||
@@ -598,9 +629,7 @@ export const createAgent = (
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'text-delta': {
|
case 'text-delta': {
|
||||||
const { visibleText, attachments } = attachmentsExtractor.push(
|
const { visibleText, events } = tagExtractor.push(chunk.text)
|
||||||
chunk.text,
|
|
||||||
)
|
|
||||||
if (visibleText) {
|
if (visibleText) {
|
||||||
if (textLoopProbeBuffer) {
|
if (textLoopProbeBuffer) {
|
||||||
textLoopProbeBuffer.push(visibleText)
|
textLoopProbeBuffer.push(visibleText)
|
||||||
@@ -610,16 +639,11 @@ export const createAgent = (
|
|||||||
delta: visibleText,
|
delta: visibleText,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (attachments.length) {
|
yield* emitTagEvents(events)
|
||||||
yield {
|
|
||||||
type: 'attachment_delta',
|
|
||||||
attachments,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'text-end': {
|
case 'text-end': {
|
||||||
const remainder = attachmentsExtractor.flushRemainder()
|
const remainder = tagExtractor.flushRemainder()
|
||||||
if (remainder.visibleText) {
|
if (remainder.visibleText) {
|
||||||
if (textLoopProbeBuffer) {
|
if (textLoopProbeBuffer) {
|
||||||
textLoopProbeBuffer.push(remainder.visibleText)
|
textLoopProbeBuffer.push(remainder.visibleText)
|
||||||
@@ -632,20 +656,15 @@ export const createAgent = (
|
|||||||
if (textLoopProbeBuffer) {
|
if (textLoopProbeBuffer) {
|
||||||
textLoopProbeBuffer.flush()
|
textLoopProbeBuffer.flush()
|
||||||
}
|
}
|
||||||
if (remainder.attachments.length) {
|
yield* emitTagEvents(remainder.events)
|
||||||
yield {
|
|
||||||
type: 'attachment_delta',
|
|
||||||
attachments: remainder.attachments,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yield {
|
yield {
|
||||||
type: 'text_end',
|
type: 'text_end',
|
||||||
metadata: chunk,
|
metadata: chunk,
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'tool-call':
|
case 'tool-call': {
|
||||||
const remainder = attachmentsExtractor.flushRemainder()
|
const remainder = tagExtractor.flushRemainder()
|
||||||
if (remainder.visibleText) {
|
if (remainder.visibleText) {
|
||||||
if (textLoopProbeBuffer) {
|
if (textLoopProbeBuffer) {
|
||||||
textLoopProbeBuffer.push(remainder.visibleText)
|
textLoopProbeBuffer.push(remainder.visibleText)
|
||||||
@@ -658,12 +677,7 @@ export const createAgent = (
|
|||||||
if (textLoopProbeBuffer) {
|
if (textLoopProbeBuffer) {
|
||||||
textLoopProbeBuffer.flush()
|
textLoopProbeBuffer.flush()
|
||||||
}
|
}
|
||||||
if (remainder.attachments.length) {
|
yield* emitTagEvents(remainder.events)
|
||||||
yield {
|
|
||||||
type: 'attachment_delta',
|
|
||||||
attachments: remainder.attachments,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yield {
|
yield {
|
||||||
type: 'tool_call_start',
|
type: 'tool_call_start',
|
||||||
toolName: chunk.toolName,
|
toolName: chunk.toolName,
|
||||||
@@ -672,7 +686,8 @@ export const createAgent = (
|
|||||||
metadata: chunk,
|
metadata: chunk,
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'tool-result':
|
}
|
||||||
|
case 'tool-result': {
|
||||||
const shouldAbortForToolLoop = toolLoopAbortCallIds.delete(chunk.toolCallId)
|
const shouldAbortForToolLoop = toolLoopAbortCallIds.delete(chunk.toolCallId)
|
||||||
yield {
|
yield {
|
||||||
type: 'tool_call_end',
|
type: 'tool_call_end',
|
||||||
@@ -691,6 +706,7 @@ export const createAgent = (
|
|||||||
throw new Error(TOOL_LOOP_DETECTED_ABORT_MESSAGE)
|
throw new Error(TOOL_LOOP_DETECTED_ABORT_MESSAGE)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'file':
|
case 'file':
|
||||||
yield {
|
yield {
|
||||||
type: 'attachment_delta',
|
type: 'attachment_delta',
|
||||||
@@ -710,6 +726,7 @@ export const createAgent = (
|
|||||||
|
|
||||||
const { messages: strippedMessages } = stripAttachmentsFromMessages(
|
const { messages: strippedMessages } = stripAttachmentsFromMessages(
|
||||||
result.messages,
|
result.messages,
|
||||||
|
[reactionsResolver],
|
||||||
)
|
)
|
||||||
yield {
|
yield {
|
||||||
type: 'agent_end',
|
type: 'agent_end',
|
||||||
|
|||||||
@@ -206,6 +206,22 @@ Rules:
|
|||||||
- No extra text inside ${quote('<attachments>...</attachments>')}
|
- No extra text inside ${quote('<attachments>...</attachments>')}
|
||||||
- The block can appear anywhere in your response; it will be parsed and stripped from visible text
|
- The block can appear anywhere in your response; it will be parsed and stripped from visible text
|
||||||
|
|
||||||
|
## Reactions
|
||||||
|
|
||||||
|
To react with an emoji to the message you are replying to, use this format in your direct response:
|
||||||
|
|
||||||
|
${block([
|
||||||
|
'<reactions>',
|
||||||
|
'- 👍',
|
||||||
|
'</reactions>',
|
||||||
|
].join('\n'))}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- One emoji per line, prefixed by ${quote('- ')}
|
||||||
|
- The block can appear anywhere in your response; it will be parsed and stripped from visible text
|
||||||
|
- This reacts to the **source message** of the current conversation (the message you are responding to)
|
||||||
|
- For reacting to messages in other channels or removing reactions, use the ${quote('react')} tool instead
|
||||||
|
|
||||||
## Schedule Tasks
|
## Schedule Tasks
|
||||||
|
|
||||||
You can create and manage schedule tasks via cron.
|
You can create and manage schedule tasks via cron.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LanguageModelUsage, ModelMessage } from 'ai'
|
import { LanguageModelUsage, ModelMessage } from 'ai'
|
||||||
import { AgentInput } from './agent'
|
import { AgentInput } from './agent'
|
||||||
import { AgentAttachment } from './attachment'
|
import { AgentAttachment } from './attachment'
|
||||||
|
import { ReactionItem } from '../utils/reactions'
|
||||||
|
|
||||||
export interface BaseAction {
|
export interface BaseAction {
|
||||||
type: string
|
type: string
|
||||||
@@ -39,6 +40,11 @@ export interface AttachmentDeltaAction extends BaseAction {
|
|||||||
attachments: AgentAttachment[]
|
attachments: AgentAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReactionDeltaAction extends BaseAction {
|
||||||
|
type: 'reaction_delta'
|
||||||
|
reactions: ReactionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface TextEndAction extends BaseAction {
|
export interface TextEndAction extends BaseAction {
|
||||||
type: 'text_end'
|
type: 'text_end'
|
||||||
}
|
}
|
||||||
@@ -84,6 +90,7 @@ export type AgentStreamAction =
|
|||||||
| TextStartAction
|
| TextStartAction
|
||||||
| TextDeltaAction
|
| TextDeltaAction
|
||||||
| AttachmentDeltaAction
|
| AttachmentDeltaAction
|
||||||
|
| ReactionDeltaAction
|
||||||
| TextEndAction
|
| TextEndAction
|
||||||
| ToolCallStartAction
|
| ToolCallStartAction
|
||||||
| ToolCallEndAction
|
| ToolCallEndAction
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import type {
|
|||||||
AgentAttachment,
|
AgentAttachment,
|
||||||
ContainerFileAttachment,
|
ContainerFileAttachment,
|
||||||
} from '../types/attachment'
|
} from '../types/attachment'
|
||||||
|
import type { TagResolver } from './tag-extractor'
|
||||||
|
import { StreamTagExtractor, extractTagsFromText } from './tag-extractor'
|
||||||
|
|
||||||
const ATTACHMENTS_START = '<attachments>'
|
// ---------------------------------------------------------------------------
|
||||||
const ATTACHMENTS_END = '</attachments>'
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a unique key for deduplication of attachments.
|
* Get a unique key for deduplication of attachments.
|
||||||
@@ -39,26 +42,41 @@ export const parseAttachmentPaths = (content: string): string[] => {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TagResolver for <attachments>
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const attachmentsResolver: TagResolver<ContainerFileAttachment> = {
|
||||||
|
tag: 'attachments',
|
||||||
|
parse(content: string): ContainerFileAttachment[] {
|
||||||
|
const paths = Array.from(new Set(parseAttachmentPaths(content)))
|
||||||
|
return paths.map((path): ContainerFileAttachment => ({ type: 'file', path }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Batch extraction (backward-compatible wrapper)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all `<attachments>...</attachments>` blocks from a text string.
|
* Extract all `<attachments>...</attachments>` blocks from a text string.
|
||||||
* Returns the cleaned text (blocks removed) and the parsed file attachments.
|
* Returns the cleaned text (blocks removed) and the parsed file attachments.
|
||||||
*/
|
*/
|
||||||
export const extractAttachmentsFromText = (text: string): { cleanedText: string; attachments: ContainerFileAttachment[] } => {
|
export const extractAttachmentsFromText = (text: string): { cleanedText: string; attachments: ContainerFileAttachment[] } => {
|
||||||
const paths: string[] = []
|
const { cleanedText, events } = extractTagsFromText(text, [attachmentsResolver])
|
||||||
const cleanedText = text.replace(
|
const attachments = events
|
||||||
/<attachments>([\s\S]*?)<\/attachments>/g,
|
.filter((e) => e.tag === 'attachments')
|
||||||
(_match, inner: string) => {
|
.flatMap((e) => e.data as ContainerFileAttachment[])
|
||||||
paths.push(...parseAttachmentPaths(inner))
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const uniquePaths = Array.from(new Set(paths))
|
|
||||||
return {
|
return {
|
||||||
cleanedText: cleanedText.replace(/\n{3,}/g, '\n\n').trim(),
|
cleanedText,
|
||||||
attachments: uniquePaths.map((path): ContainerFileAttachment => ({ type: 'file', path })),
|
attachments: dedupeAttachments(attachments) as ContainerFileAttachment[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Message-level stripping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard: checks whether a content part is a TextPart.
|
* Type guard: checks whether a content part is a TextPart.
|
||||||
*/
|
*/
|
||||||
@@ -72,13 +90,25 @@ const isTextPart = (part: unknown): part is TextPart => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip `<attachments>` blocks from all assistant messages in a message list.
|
* Strip all registered tag blocks from assistant messages in a message list.
|
||||||
|
* Accepts additional resolvers to strip beyond `<attachments>` (e.g. `<reactions>`).
|
||||||
* Returns the cleaned messages and a deduplicated list of attachments found.
|
* Returns the cleaned messages and a deduplicated list of attachments found.
|
||||||
*/
|
*/
|
||||||
export const stripAttachmentsFromMessages = (
|
export const stripAttachmentsFromMessages = (
|
||||||
messages: ModelMessage[]
|
messages: ModelMessage[],
|
||||||
|
extraResolvers: TagResolver[] = [],
|
||||||
): { messages: ModelMessage[]; attachments: ContainerFileAttachment[] } => {
|
): { messages: ModelMessage[]; attachments: ContainerFileAttachment[] } => {
|
||||||
const allAttachments: ContainerFileAttachment[] = []
|
const allAttachments: ContainerFileAttachment[] = []
|
||||||
|
const resolvers: TagResolver[] = [attachmentsResolver, ...extraResolvers]
|
||||||
|
|
||||||
|
const cleanText = (text: string): string => {
|
||||||
|
const { cleanedText, events } = extractTagsFromText(text, resolvers)
|
||||||
|
const attachments = events
|
||||||
|
.filter((e) => e.tag === 'attachments')
|
||||||
|
.flatMap((e) => e.data as ContainerFileAttachment[])
|
||||||
|
allAttachments.push(...attachments)
|
||||||
|
return cleanedText
|
||||||
|
}
|
||||||
|
|
||||||
const stripped = messages.map((msg): ModelMessage => {
|
const stripped = messages.map((msg): ModelMessage => {
|
||||||
if (msg.role !== 'assistant') return msg
|
if (msg.role !== 'assistant') return msg
|
||||||
@@ -87,17 +117,13 @@ export const stripAttachmentsFromMessages = (
|
|||||||
const { content } = assistantMsg
|
const { content } = assistantMsg
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
const { cleanedText, attachments } = extractAttachmentsFromText(content)
|
return { ...assistantMsg, content: cleanText(content) }
|
||||||
allAttachments.push(...attachments)
|
|
||||||
return { ...assistantMsg, content: cleanedText }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
const newParts = content.map(part => {
|
const newParts = content.map(part => {
|
||||||
if (!isTextPart(part)) return part
|
if (!isTextPart(part)) return part
|
||||||
const { cleanedText, attachments } = extractAttachmentsFromText(part.text)
|
return { ...part, text: cleanText(part.text) }
|
||||||
allAttachments.push(...attachments)
|
|
||||||
return { ...part, text: cleanedText }
|
|
||||||
})
|
})
|
||||||
return { ...assistantMsg, content: newParts }
|
return { ...assistantMsg, content: newParts }
|
||||||
}
|
}
|
||||||
@@ -112,7 +138,7 @@ export const stripAttachmentsFromMessages = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Streaming extractor
|
// Streaming extractor (backward-compatible wrapper)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface AttachmentsStreamResult {
|
export interface AttachmentsStreamResult {
|
||||||
@@ -121,81 +147,35 @@ export interface AttachmentsStreamResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Incremental state-machine that intercepts `<attachments>...</attachments>`
|
* Backward-compatible streaming extractor that delegates to {@link StreamTagExtractor}.
|
||||||
* blocks from a stream of text deltas. Text outside those blocks is passed
|
* Intercepts `<attachments>...</attachments>` blocks from a stream of text deltas.
|
||||||
* through as `visibleText`; completed blocks are emitted as `attachments`.
|
|
||||||
*/
|
*/
|
||||||
export class AttachmentsStreamExtractor {
|
export class AttachmentsStreamExtractor {
|
||||||
private state: 'text' | 'attachments' = 'text'
|
private inner: StreamTagExtractor
|
||||||
private buffer = ''
|
|
||||||
private attachmentsBuffer = ''
|
|
||||||
|
|
||||||
/**
|
constructor() {
|
||||||
* Feed a new text delta into the extractor.
|
this.inner = new StreamTagExtractor([attachmentsResolver])
|
||||||
*/
|
|
||||||
push(delta: string): AttachmentsStreamResult {
|
|
||||||
this.buffer += delta
|
|
||||||
let visible = ''
|
|
||||||
const attachments: ContainerFileAttachment[] = []
|
|
||||||
|
|
||||||
while (this.buffer.length > 0) {
|
|
||||||
if (this.state === 'text') {
|
|
||||||
const idx = this.buffer.indexOf(ATTACHMENTS_START)
|
|
||||||
if (idx === -1) {
|
|
||||||
// Emit everything except a small tail that could be the start of the opening tag.
|
|
||||||
const keep = Math.min(this.buffer.length, ATTACHMENTS_START.length - 1)
|
|
||||||
const emit = this.buffer.slice(0, this.buffer.length - keep)
|
|
||||||
visible += emit
|
|
||||||
this.buffer = this.buffer.slice(this.buffer.length - keep)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
visible += this.buffer.slice(0, idx)
|
|
||||||
this.buffer = this.buffer.slice(idx + ATTACHMENTS_START.length)
|
|
||||||
this.attachmentsBuffer = ''
|
|
||||||
this.state = 'attachments'
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// state === 'attachments'
|
|
||||||
const endIdx = this.buffer.indexOf(ATTACHMENTS_END)
|
|
||||||
if (endIdx === -1) {
|
|
||||||
const keep = Math.min(this.buffer.length, ATTACHMENTS_END.length - 1)
|
|
||||||
const take = this.buffer.slice(0, this.buffer.length - keep)
|
|
||||||
this.attachmentsBuffer += take
|
|
||||||
this.buffer = this.buffer.slice(this.buffer.length - keep)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
this.attachmentsBuffer += this.buffer.slice(0, endIdx)
|
|
||||||
const paths = parseAttachmentPaths(this.attachmentsBuffer)
|
|
||||||
for (const path of paths) {
|
|
||||||
attachments.push({ type: 'file', path })
|
|
||||||
}
|
|
||||||
this.buffer = this.buffer.slice(endIdx + ATTACHMENTS_END.length)
|
|
||||||
this.attachmentsBuffer = ''
|
|
||||||
this.state = 'text'
|
|
||||||
}
|
|
||||||
|
|
||||||
return { visibleText: visible, attachments: dedupeAttachments(attachments) as ContainerFileAttachment[] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
push(delta: string): AttachmentsStreamResult {
|
||||||
* Flush any remaining buffered content. Call this when the stream ends.
|
const { visibleText, events } = this.inner.push(delta)
|
||||||
* If an `<attachments>` block was not properly closed, the raw text is
|
const attachments = events
|
||||||
* returned as `visibleText` to avoid data loss.
|
.filter((e) => e.tag === 'attachments')
|
||||||
*/
|
.flatMap((e) => e.data as ContainerFileAttachment[])
|
||||||
flushRemainder(): AttachmentsStreamResult {
|
return {
|
||||||
if (this.state === 'text') {
|
visibleText,
|
||||||
const out = this.buffer
|
attachments: dedupeAttachments(attachments) as ContainerFileAttachment[],
|
||||||
this.buffer = ''
|
}
|
||||||
return { visibleText: out, attachments: [] }
|
}
|
||||||
|
|
||||||
|
flushRemainder(): AttachmentsStreamResult {
|
||||||
|
const { visibleText, events } = this.inner.flushRemainder()
|
||||||
|
const attachments = events
|
||||||
|
.filter((e) => e.tag === 'attachments')
|
||||||
|
.flatMap((e) => e.data as ContainerFileAttachment[])
|
||||||
|
return {
|
||||||
|
visibleText,
|
||||||
|
attachments: dedupeAttachments(attachments) as ContainerFileAttachment[],
|
||||||
}
|
}
|
||||||
// Unclosed attachments block — treat it as literal text to avoid data loss.
|
|
||||||
const out = `${ATTACHMENTS_START}${this.attachmentsBuffer}${this.buffer}`
|
|
||||||
this.state = 'text'
|
|
||||||
this.buffer = ''
|
|
||||||
this.attachmentsBuffer = ''
|
|
||||||
return { visibleText: out, attachments: [] }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { TagResolver } from './tag-extractor'
|
||||||
|
|
||||||
|
export interface ReactionItem {
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse emoji entries from the inner content of a `<reactions>` block.
|
||||||
|
* Each line should be formatted as `- 👍`.
|
||||||
|
*/
|
||||||
|
export const parseReactionEmojis = (content: string): string[] => {
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.map(line => {
|
||||||
|
if (!line.startsWith('-')) return ''
|
||||||
|
return line.slice(1).trim()
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reactionsResolver: TagResolver<ReactionItem> = {
|
||||||
|
tag: 'reactions',
|
||||||
|
parse(content: string): ReactionItem[] {
|
||||||
|
const emojis = Array.from(new Set(parseReactionEmojis(content)))
|
||||||
|
return emojis.map((emoji): ReactionItem => ({ emoji }))
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Generic extensible tag-interception system.
|
||||||
|
*
|
||||||
|
* Register TagResolver instances (e.g. attachments, reactions) and both the
|
||||||
|
* batch extractor and the streaming state-machine will intercept the
|
||||||
|
* corresponding `<tag>...</tag>` blocks, stripping them from visible text and
|
||||||
|
* forwarding the parsed payload through {@link TagEvent} objects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TagResolver<T = unknown> {
|
||||||
|
tag: string
|
||||||
|
parse(content: string): T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagEvent {
|
||||||
|
tag: string
|
||||||
|
data: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagStreamResult {
|
||||||
|
visibleText: string
|
||||||
|
events: TagEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Batch extractor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all registered tag blocks from a complete text string.
|
||||||
|
* Returns the cleaned text (blocks removed) and a list of tag events.
|
||||||
|
*/
|
||||||
|
export function extractTagsFromText(
|
||||||
|
text: string,
|
||||||
|
resolvers: TagResolver[],
|
||||||
|
): { cleanedText: string; events: TagEvent[] } {
|
||||||
|
const events: TagEvent[] = []
|
||||||
|
let cleaned = text
|
||||||
|
for (const resolver of resolvers) {
|
||||||
|
const open = `<${resolver.tag}>`
|
||||||
|
const close = `</${resolver.tag}>`
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`${escapeRegExp(open)}([\\s\\S]*?)${escapeRegExp(close)}`,
|
||||||
|
'g',
|
||||||
|
)
|
||||||
|
cleaned = cleaned.replace(pattern, (_match, inner: string) => {
|
||||||
|
const parsed = resolver.parse(inner)
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
events.push({ tag: resolver.tag, data: parsed })
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cleanedText: cleaned.replace(/\n{3,}/g, '\n\n').trim(),
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streaming extractor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ResolverMeta {
|
||||||
|
resolver: TagResolver
|
||||||
|
openTag: string
|
||||||
|
closeTag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental state-machine that intercepts multiple `<tag>...</tag>` blocks
|
||||||
|
* from a stream of text deltas.
|
||||||
|
*
|
||||||
|
* Text outside registered blocks is passed through as `visibleText`; completed
|
||||||
|
* blocks are emitted as {@link TagEvent} entries.
|
||||||
|
*/
|
||||||
|
export class StreamTagExtractor {
|
||||||
|
private metas: ResolverMeta[]
|
||||||
|
private maxOpenLen: number
|
||||||
|
private state: 'text' | 'inside' = 'text'
|
||||||
|
private activeMeta: ResolverMeta | null = null
|
||||||
|
private buffer = ''
|
||||||
|
private tagBuffer = ''
|
||||||
|
|
||||||
|
constructor(resolvers: TagResolver[]) {
|
||||||
|
this.metas = resolvers.map((resolver) => ({
|
||||||
|
resolver,
|
||||||
|
openTag: `<${resolver.tag}>`,
|
||||||
|
closeTag: `</${resolver.tag}>`,
|
||||||
|
}))
|
||||||
|
this.maxOpenLen = Math.max(...this.metas.map((m) => m.openTag.length), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
push(delta: string): TagStreamResult {
|
||||||
|
this.buffer += delta
|
||||||
|
let visible = ''
|
||||||
|
const events: TagEvent[] = []
|
||||||
|
|
||||||
|
while (this.buffer.length > 0) {
|
||||||
|
if (this.state === 'text') {
|
||||||
|
let earliestIdx = -1
|
||||||
|
let matchedMeta: ResolverMeta | null = null
|
||||||
|
|
||||||
|
for (const meta of this.metas) {
|
||||||
|
const idx = this.buffer.indexOf(meta.openTag)
|
||||||
|
if (idx !== -1 && (earliestIdx === -1 || idx < earliestIdx)) {
|
||||||
|
earliestIdx = idx
|
||||||
|
matchedMeta = meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (earliestIdx === -1) {
|
||||||
|
const keep = Math.min(this.buffer.length, this.maxOpenLen - 1)
|
||||||
|
const emit = this.buffer.slice(0, this.buffer.length - keep)
|
||||||
|
visible += emit
|
||||||
|
this.buffer = this.buffer.slice(this.buffer.length - keep)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
visible += this.buffer.slice(0, earliestIdx)
|
||||||
|
this.buffer = this.buffer.slice(earliestIdx + matchedMeta!.openTag.length)
|
||||||
|
this.tagBuffer = ''
|
||||||
|
this.activeMeta = matchedMeta
|
||||||
|
this.state = 'inside'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// state === 'inside'
|
||||||
|
const closeTag = this.activeMeta!.closeTag
|
||||||
|
const endIdx = this.buffer.indexOf(closeTag)
|
||||||
|
if (endIdx === -1) {
|
||||||
|
const keep = Math.min(this.buffer.length, closeTag.length - 1)
|
||||||
|
const take = this.buffer.slice(0, this.buffer.length - keep)
|
||||||
|
this.tagBuffer += take
|
||||||
|
this.buffer = this.buffer.slice(this.buffer.length - keep)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tagBuffer += this.buffer.slice(0, endIdx)
|
||||||
|
const parsed = this.activeMeta!.resolver.parse(this.tagBuffer)
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
events.push({ tag: this.activeMeta!.resolver.tag, data: parsed })
|
||||||
|
}
|
||||||
|
this.buffer = this.buffer.slice(endIdx + closeTag.length)
|
||||||
|
this.tagBuffer = ''
|
||||||
|
this.activeMeta = null
|
||||||
|
this.state = 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { visibleText: visible, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush remaining buffered content. Call when the stream ends.
|
||||||
|
* Unclosed tag blocks are returned as literal `visibleText` to avoid data loss.
|
||||||
|
*/
|
||||||
|
flushRemainder(): TagStreamResult {
|
||||||
|
if (this.state === 'text') {
|
||||||
|
const out = this.buffer
|
||||||
|
this.buffer = ''
|
||||||
|
return { visibleText: out, events: [] }
|
||||||
|
}
|
||||||
|
const meta = this.activeMeta!
|
||||||
|
const out = `${meta.openTag}${this.tagBuffer}${this.buffer}`
|
||||||
|
this.state = 'text'
|
||||||
|
this.buffer = ''
|
||||||
|
this.tagBuffer = ''
|
||||||
|
this.activeMeta = null
|
||||||
|
return { visibleText: out, events: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user