package utils
import (
"bytes"
"github.com/libaxuan/cursor2api-go/models"
"encoding/json"
"strings"
)
const (
thinkingStartTag = ""
thinkingEndTag = ""
invokeEndTag = ""
)
// CursorProtocolParser 将 Cursor 的纯文本增量转换为内部文本/thinking/tool_call 事件
type CursorProtocolParser struct {
config models.CursorParseConfig
pending string
}
// NewCursorProtocolParser 创建新的协议解析器
func NewCursorProtocolParser(config models.CursorParseConfig) *CursorProtocolParser {
return &CursorProtocolParser{config: config}
}
// Feed 喂入一个上游增量片段
func (p *CursorProtocolParser) Feed(chunk string) []models.AssistantEvent {
if chunk == "" {
return nil
}
p.pending += chunk
return p.extract(false)
}
// Finish 在流结束时刷新剩余缓冲
func (p *CursorProtocolParser) Finish() []models.AssistantEvent {
events := p.extract(true)
if p.pending != "" {
events = append(events, models.AssistantEvent{
Kind: models.AssistantEventText,
Text: p.pending,
})
p.pending = ""
}
return events
}
func (p *CursorProtocolParser) extract(final bool) []models.AssistantEvent {
events := make([]models.AssistantEvent, 0, 4)
for len(p.pending) > 0 {
idx, kind := p.findNextSpecial()
if idx < 0 {
keep := 0
if !final {
keep = p.partialStartKeep()
}
if len(p.pending) <= keep {
break
}
text := p.pending[:len(p.pending)-keep]
p.pending = p.pending[len(p.pending)-keep:]
if text != "" {
events = append(events, models.AssistantEvent{
Kind: models.AssistantEventText,
Text: text,
})
}
continue
}
if idx > 0 {
text := p.pending[:idx]
p.pending = p.pending[idx:]
if text != "" {
events = append(events, models.AssistantEvent{
Kind: models.AssistantEventText,
Text: text,
})
}
continue
}
switch kind {
case models.AssistantEventThinking:
if event, ok := p.tryParseThinking(final); ok {
events = append(events, event)
continue
}
if !final {
return events
}
case models.AssistantEventToolCall:
if event, ok := p.tryParseToolCall(final); ok {
events = append(events, event)
continue
}
if !final {
return events
}
}
events = append(events, models.AssistantEvent{
Kind: models.AssistantEventText,
Text: p.pending,
})
p.pending = ""
}
return events
}
func (p *CursorProtocolParser) findNextSpecial() (int, models.AssistantEventKind) {
bestIdx := -1
bestKind := models.AssistantEventText
if p.config.ThinkingEnabled {
if idx := strings.Index(p.pending, thinkingStartTag); idx >= 0 {
bestIdx = idx
bestKind = models.AssistantEventThinking
}
}
if p.config.TriggerSignal != "" {
if idx := strings.Index(p.pending, p.config.TriggerSignal); idx >= 0 && (bestIdx < 0 || idx < bestIdx) {
bestIdx = idx
bestKind = models.AssistantEventToolCall
}
}
return bestIdx, bestKind
}
func (p *CursorProtocolParser) partialStartKeep() int {
maxKeep := 0
if p.config.ThinkingEnabled {
maxKeep = max(maxKeep, longestPrefixSuffix(p.pending, thinkingStartTag))
}
if p.config.TriggerSignal != "" {
maxKeep = max(maxKeep, longestPrefixSuffix(p.pending, p.config.TriggerSignal))
}
return maxKeep
}
func (p *CursorProtocolParser) tryParseThinking(final bool) (models.AssistantEvent, bool) {
if !strings.HasPrefix(p.pending, thinkingStartTag) {
return models.AssistantEvent{}, false
}
endIdx := strings.Index(p.pending[len(thinkingStartTag):], thinkingEndTag)
if endIdx < 0 {
if final {
return models.AssistantEvent{
Kind: models.AssistantEventText,
Text: p.pending,
}, true
}
return models.AssistantEvent{}, false
}
start := len(thinkingStartTag)
end := start + endIdx
content := p.pending[start:end]
p.pending = p.pending[end+len(thinkingEndTag):]
return models.AssistantEvent{
Kind: models.AssistantEventThinking,
Thinking: content,
}, true
}
func (p *CursorProtocolParser) tryParseToolCall(final bool) (models.AssistantEvent, bool) {
if p.config.TriggerSignal == "" || !strings.HasPrefix(p.pending, p.config.TriggerSignal) {
return models.AssistantEvent{}, false
}
endIdx := strings.Index(p.pending, invokeEndTag)
if endIdx < 0 {
if final {
return models.AssistantEvent{
Kind: models.AssistantEventText,
Text: p.pending,
}, true
}
return models.AssistantEvent{}, false
}
segment := p.pending[:endIdx+len(invokeEndTag)]
call, ok := parseToolCallSegment(segment, p.config.TriggerSignal)
p.pending = p.pending[endIdx+len(invokeEndTag):]
if !ok {
return models.AssistantEvent{
Kind: models.AssistantEventText,
Text: segment,
}, true
}
return models.AssistantEvent{
Kind: models.AssistantEventToolCall,
ToolCall: call,
}, true
}
func parseToolCallSegment(segment, triggerSignal string) (*models.ToolCall, bool) {
body := strings.TrimSpace(strings.TrimPrefix(segment, triggerSignal))
if !strings.HasPrefix(body, "")
if openEnd < 0 {
return nil, false
}
openTag := body[:openEnd+1]
if !strings.HasSuffix(body, invokeEndTag) {
return nil, false
}
name := extractInvokeName(openTag)
if name == "" {
return nil, false
}
args := strings.TrimSpace(body[openEnd+1 : len(body)-len(invokeEndTag)])
if args == "" {
args = "{}"
}
var compact bytes.Buffer
if err := json.Compact(&compact, []byte(args)); err != nil {
return nil, false
}
return &models.ToolCall{
ID: "call_" + GenerateRandomString(24),
Type: "function",
Function: models.FunctionCall{
Name: name,
Arguments: compact.String(),
},
}, true
}
func extractInvokeName(openTag string) string {
nameIdx := strings.Index(openTag, `name="`)
if nameIdx < 0 {
return ""
}
value := openTag[nameIdx+len(`name="`):]
endIdx := strings.Index(value, `"`)
if endIdx < 0 {
return ""
}
return strings.TrimSpace(value[:endIdx])
}
func longestPrefixSuffix(text, token string) int {
maxLen := min(len(text), len(token)-1)
for size := maxLen; size > 0; size-- {
if strings.HasSuffix(text, token[:size]) {
return size
}
}
return 0
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}