184 lines
4.4 KiB
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, "")
|
|
}
|