opencode-cursor-agent/internal/converter/convert.go

184 lines
4.4 KiB
Go

package converter
import (
"encoding/json"
"fmt"
"strings"
"github.com/daniel/cursor-adapter/internal/types"
)
// CursorLine 代表 Cursor CLI stream-json 的一行。
type CursorLine struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
Message *CursorMessage `json:"message,omitempty"`
Result string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
Usage *CursorUsage `json:"usage,omitempty"`
}
// FlexibleContent 可以是 string 或 []CursorContent。
type FlexibleContent []CursorContent
func (fc *FlexibleContent) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
*fc = []CursorContent{{Type: "text", Text: s}}
return nil
}
var items []CursorContent
if err := json.Unmarshal(data, &items); err != nil {
return err
}
*fc = items
return nil
}
type CursorMessage struct {
Role string `json:"role"`
Content FlexibleContent `json:"content"`
}
type CursorContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
type CursorUsage struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
}
// ConvertResult 是轉換一行的結果。
type ConvertResult struct {
Chunk *types.ChatCompletionChunk
Done bool
Skip bool
Error error
Usage *CursorUsage
}
// StreamParser tracks accumulated assistant output so the OpenAI stream only
// emits newly appended text, not the full Cursor message each time.
type StreamParser struct {
chatID string
accumulated string
lastRawText string
}
func NewStreamParser(chatID string) *StreamParser {
return &StreamParser{chatID: chatID}
}
func (p *StreamParser) Parse(line string) ConvertResult {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return ConvertResult{Skip: true}
}
var cl CursorLine
if err := json.Unmarshal([]byte(trimmed), &cl); err != nil {
return ConvertResult{Error: fmt.Errorf("unmarshal error: %w", err)}
}
switch cl.Type {
case "system", "user":
return ConvertResult{Skip: true}
case "assistant":
content := ExtractContent(cl.Message)
return p.emitAssistantDelta(content)
case "result":
if cl.IsError {
errMsg := cl.Result
if errMsg == "" {
errMsg = "unknown cursor error"
}
return ConvertResult{Error: fmt.Errorf("cursor error: %s", errMsg)}
}
return ConvertResult{
Done: true,
Usage: cl.Usage,
}
default:
return ConvertResult{Skip: true}
}
}
func (p *StreamParser) ParseRawText(content string) ConvertResult {
content = strings.TrimSpace(content)
if content == "" {
return ConvertResult{Skip: true}
}
if content == p.lastRawText {
return ConvertResult{Skip: true}
}
p.lastRawText = content
chunk := types.NewChatCompletionChunk(p.chatID, 0, "", types.Delta{
Content: &content,
})
return ConvertResult{Chunk: &chunk}
}
// emitAssistantDelta handles both output modes the Cursor CLI can use:
//
// - CUMULATIVE: each assistant message contains the full text so far
// (text.startsWith(accumulated)). We emit only the new suffix and
// replace accumulated with the full text.
// - INCREMENTAL: each assistant message contains just the new fragment.
// We emit the fragment verbatim and append it to accumulated.
//
// Either way, the duplicate final "assistant" message that Cursor CLI emits
// at the end of a session is caught by the content == accumulated check and
// skipped.
func (p *StreamParser) emitAssistantDelta(content string) ConvertResult {
if content == "" {
return ConvertResult{Skip: true}
}
if content == p.accumulated {
return ConvertResult{Skip: true}
}
var delta string
if p.accumulated != "" && strings.HasPrefix(content, p.accumulated) {
delta = content[len(p.accumulated):]
p.accumulated = content
} else {
delta = content
p.accumulated += content
}
if delta == "" {
return ConvertResult{Skip: true}
}
chunk := types.NewChatCompletionChunk(p.chatID, 0, "", types.Delta{
Content: &delta,
})
return ConvertResult{Chunk: &chunk}
}
// ConvertLine 將一行 Cursor stream-json 轉換為 OpenAI SSE chunk。
func ConvertLine(line string, chatID string) ConvertResult {
return NewStreamParser(chatID).Parse(line)
}
// ExtractContent 從 CursorMessage 提取所有文字內容。
func ExtractContent(msg *CursorMessage) string {
if msg == nil {
return ""
}
var parts []string
for _, c := range msg.Content {
if c.Text != "" {
parts = append(parts, c.Text)
}
}
return strings.Join(parts, "")
}