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, "") }