| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463 |
- package opencode
-
- import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
- )
-
- // DefaultOpenCodePort opencode 服务默认端口(用于测试)
- const DefaultOpenCodePort = 8787
-
- // DirectClient opencode API 客户端(直接 HTTP 调用,不使用 SDK)
- type DirectClient struct {
- baseURL string
- port int
- httpClient *http.Client
- }
-
- // 确保 DirectClient 实现 OpenCodeClient 接口
- var _ OpenCodeClient = (*DirectClient)(nil)
-
- // NewDirectClient 创建新的直接 HTTP opencode 客户端
- func NewDirectClient(port int) (*DirectClient, error) {
- baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
-
- // 测试连接
- if err := testDirectConnection(baseURL); err != nil {
- return nil, fmt.Errorf("无法连接到 opencode 服务: %w", err)
- }
-
- return &DirectClient{
- baseURL: baseURL,
- port: port,
- httpClient: &http.Client{
- Timeout: 30 * time.Second,
- },
- }, nil
- }
-
- // testDirectConnection 测试连接是否可用
- func testDirectConnection(baseURL string) error {
- client := &http.Client{Timeout: 5 * time.Second}
- resp, err := client.Get(baseURL + "/global/health")
- if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != 200 {
- return fmt.Errorf("服务不可用,状态码: %d", resp.StatusCode)
- }
-
- return nil
- }
-
- // CreateSession 创建新会话(直接 HTTP 调用)
- func (c *DirectClient) CreateSession(ctx context.Context, title string) (*Session, error) {
- // 构造请求体
- reqBody := map[string]interface{}{
- "title": title,
- }
- jsonBody, err := json.Marshal(reqBody)
- if err != nil {
- return nil, fmt.Errorf("编码请求失败: %w", err)
- }
-
- // 发送 HTTP 请求
- req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/session", bytes.NewBuffer(jsonBody))
- if err != nil {
- return nil, fmt.Errorf("创建请求失败: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("HTTP请求失败: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("创建会话失败,状态码: %d, 响应体: %s", resp.StatusCode, string(body))
- }
-
- // 解析响应
- var session Session
- body, _ := io.ReadAll(resp.Body)
- if err := json.Unmarshal(body, &session); err != nil {
- return nil, fmt.Errorf("解析会话响应失败: %w", err)
- }
-
- fmt.Fprintf(os.Stderr, "[opencode-direct-client] 创建会话成功: %s\n", session.ID)
- return &session, nil
- }
-
- // SendPrompt 发送消息(同步,直接 HTTP 调用)
- func (c *DirectClient) SendPrompt(ctx context.Context, sessionID string, prompt *PromptRequest) (*PromptResponse, error) {
- // 序列化请求体
- reqBody, err := json.Marshal(prompt)
- if err != nil {
- return nil, fmt.Errorf("编码请求失败: %w", err)
- }
-
- // 发送 HTTP 请求到 /session/{id}/message 端点(基于 svc-worker 测试)
- url := fmt.Sprintf("%s/session/%s/message", c.baseURL, sessionID)
- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(reqBody))
- if err != nil {
- return nil, fmt.Errorf("创建请求失败: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("发送消息失败: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("请求失败,状态码: %d, 响应体: %s", resp.StatusCode, string(body))
- }
-
- // 解析响应
- body, _ := io.ReadAll(resp.Body)
-
- // 首先尝试解析为完整的 PromptResponse
- var response PromptResponse
- if err := json.Unmarshal(body, &response); err == nil && response.Info.ID != "" {
- fmt.Fprintf(os.Stderr, "[opencode-direct-client] 发送消息成功,消息ID: %s\n", response.Info.ID)
- return &response, nil
- }
-
- // 如果失败,尝试解析为直接的消息响应
- var directResponse struct {
- ID string `json:"id"`
- Role string `json:"role"`
- Content string `json:"content"`
- Parts []struct {
- Type string `json:"type"`
- Text string `json:"text"`
- } `json:"parts"`
- Model struct {
- ID string `json:"id"`
- ProviderID string `json:"providerID"`
- } `json:"model"`
- }
-
- if err := json.Unmarshal(body, &directResponse); err != nil {
- return nil, fmt.Errorf("解析响应失败: %w", err)
- }
-
- // 构造标准的 PromptResponse
- response = PromptResponse{
- Info: AssistantMessage{
- ID: directResponse.ID,
- Role: directResponse.Role,
- SessionID: sessionID,
- Content: directResponse.Content,
- Agent: "opencode",
- ModelID: directResponse.Model.ID,
- ProviderID: directResponse.Model.ProviderID,
- Tokens: TokenInfo{
- Input: 0,
- Output: 0,
- },
- Time: map[string]interface{}{
- "created": time.Now().Unix(),
- },
- },
- }
-
- // 转换 parts
- if len(directResponse.Parts) > 0 {
- for _, part := range directResponse.Parts {
- response.Parts = append(response.Parts, map[string]string{
- "type": part.Type,
- "text": part.Text,
- })
- }
- }
-
- fmt.Fprintf(os.Stderr, "[opencode-direct-client] 发送消息成功,消息ID: %s\n", response.Info.ID)
- return &response, nil
- }
-
- // SendPromptStream 发送消息(流式,直接 HTTP 调用)
- func (c *DirectClient) SendPromptStream(ctx context.Context, sessionID string, prompt *PromptRequest) (<-chan string, error) {
- reqBody, err := json.Marshal(prompt)
- if err != nil {
- return nil, fmt.Errorf("编码请求失败: %w", err)
- }
-
- fmt.Printf("🔍 [opencode.DirectClient] 发送流式请求到 session: %s\n", sessionID)
- fmt.Printf("🔍 [opencode.DirectClient] 请求体: %s\n", string(reqBody))
- fmt.Printf("🔍 [opencode.DirectClient] 端口: %d\n", c.port)
-
- // 发送异步请求到 opencode(触发AI处理)
- url := fmt.Sprintf("%s/session/%s/prompt_async", c.baseURL, sessionID)
- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(reqBody))
- if err != nil {
- return nil, fmt.Errorf("创建请求失败: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "text/event-stream")
-
- fmt.Printf("🔍 [opencode.DirectClient] 请求 URL: %s\n", url)
- fmt.Printf("🔍 [opencode.DirectClient] 请求头: %v\n", req.Header)
-
- // 发送请求,不等待SSE响应(事件通过EventDispatcher分发)
- sseClient := &http.Client{
- Timeout: 30 * time.Second, // 设置超时,避免长时间阻塞
- }
- resp, err := sseClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("发送请求失败: %w", err)
- }
- defer resp.Body.Close()
-
- fmt.Printf("🔍 [opencode.DirectClient] 响应状态: %d\n", resp.StatusCode)
- fmt.Printf("🔍 [opencode.DirectClient] 响应头: %v\n", resp.Header)
-
- if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
- body, _ := io.ReadAll(resp.Body)
- fmt.Printf("🔍 [opencode.DirectClient] 错误响应体: %s\n", string(body))
- return nil, fmt.Errorf("请求失败,状态码: %d", resp.StatusCode)
- }
-
- // 异步读取响应体(避免资源泄漏)
- go func() {
- io.Copy(io.Discard, resp.Body)
- }()
-
- fmt.Printf("🔍 [opencode.DirectClient] 异步请求发送成功,事件将通过EventDispatcher分发\n")
-
- // 返回一个立即关闭的空通道(保持接口兼容,实际事件通过EventDispatcher分发)
- ch := make(chan string)
- close(ch)
- return ch, nil
- }
-
- // subscribeGlobalEvents 订阅全局事件流
- func (c *DirectClient) subscribeGlobalEvents(ctx context.Context) (<-chan string, error) {
- url := c.baseURL + "/global/event"
- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("创建事件订阅请求失败: %w", err)
- }
- req.Header.Set("Accept", "text/event-stream")
-
- fmt.Printf("🔍 [opencode.DirectClient] 订阅全局事件流: %s\n", url)
-
- // 为SSE连接创建独立的httpClient,不设置超时限制
- sseClient := &http.Client{
- // 不设置Timeout,允许长连接
- // Timeout: 0 表示无超时限制
- }
-
- resp, err := sseClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("订阅事件流失败: %w", err)
- }
-
- if resp.StatusCode != http.StatusOK {
- resp.Body.Close()
- return nil, fmt.Errorf("事件流订阅失败,状态码: %d", resp.StatusCode)
- }
-
- ch := make(chan string, 100)
- go func() {
- defer resp.Body.Close()
- defer close(ch)
-
- scanner := bufio.NewScanner(resp.Body)
- eventCount := 0
-
- for scanner.Scan() {
- line := scanner.Text()
- if strings.HasPrefix(line, "data: ") {
- data := strings.TrimPrefix(line, "data: ")
- eventCount++
- fmt.Printf("🔍 [opencode.DirectClient] 收到全局事件[%d]: %s\n", eventCount, data)
-
- // 写入日志文件用于分析
- writeStreamLog("", data)
-
- select {
- case ch <- data:
- case <-ctx.Done():
- fmt.Printf("🔍 [opencode.DirectClient] 全局事件上下文取消\n")
- return
- }
- }
- }
-
- if err := scanner.Err(); err != nil {
- // 区分正常取消和错误
- if ctx.Err() != nil {
- fmt.Printf("🔍 [opencode.DirectClient] 全局事件流正常结束(上下文取消)\n")
- } else {
- fmt.Printf("🔍 [opencode.DirectClient] 扫描事件流错误: %v\n", err)
- }
- }
- }()
-
- return ch, nil
- }
-
- // GetSession 获取会话信息
- func (c *DirectClient) GetSession(ctx context.Context, sessionID string) (*Session, error) {
- url := fmt.Sprintf("%s/session/%s", c.baseURL, sessionID)
-
- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("创建请求失败: %w", err)
- }
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("获取会话失败: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("获取会话失败,状态码: %d, 响应体: %s", resp.StatusCode, string(body))
- }
-
- var session Session
- if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
- return nil, fmt.Errorf("解析会话响应失败: %w", err)
- }
-
- return &session, nil
- }
-
- // ListSessions 获取会话列表
- func (c *DirectClient) ListSessions(ctx context.Context) ([]Session, error) {
- url := c.baseURL + "/session"
-
- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("创建请求失败: %w", err)
- }
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("获取会话列表失败: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("获取会话列表失败,状态码: %d, 响应体: %s", resp.StatusCode, string(body))
- }
-
- var sessions []Session
- if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
- return nil, fmt.Errorf("解析会话列表失败: %w", err)
- }
-
- return sessions, nil
- }
-
- // GetBaseURL 获取基础URL
- func (c *DirectClient) GetBaseURL() string {
- return c.baseURL
- }
-
- // GetPort 获取端口
- func (c *DirectClient) GetPort() int {
- return c.port
- }
-
- // GetSessionMessages 获取会话消息历史
- func (c *DirectClient) GetSessionMessages(ctx context.Context, sessionID string, limit int) ([]SessionMessage, error) {
- // 构造URL
- url := fmt.Sprintf("%s/session/%s/message", c.baseURL, sessionID)
-
- // 添加查询参数
- if limit > 0 {
- url = fmt.Sprintf("%s?limit=%d", url, limit)
- }
-
- // 发送HTTP请求
- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("创建请求失败: %w", err)
- }
- req.Header.Set("Accept", "application/json")
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("获取会话消息失败: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("获取会话消息失败,状态码: %d, 响应体: %s", resp.StatusCode, string(body))
- }
-
- // 解析响应
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("读取响应体失败: %w", err)
- }
-
- // 解析JSON响应
- var messages []SessionMessage
- if err := json.Unmarshal(body, &messages); err != nil {
- return nil, fmt.Errorf("解析消息响应失败: %w", err)
- }
-
- fmt.Fprintf(os.Stderr, "[opencode-direct-client] 获取会话消息成功: sessionID=%s, count=%d\n", sessionID, len(messages))
- return messages, nil
- }
-
- // writeStreamLog 将流式数据写入日志文件用于分析
- func writeStreamLog(sessionID string, data string) {
- // 创建日志目录
- logDir := "/Users/kenqdy/Documents/v-bdx-workspace/svc-code/logs"
- if err := os.MkdirAll(logDir, 0755); err != nil {
- fmt.Printf("🔍 [opencode-direct-client] 创建日志目录失败: %v\n", err)
- return
- }
-
- // 生成日志文件名,按日期和会话ID组织
- dateStr := time.Now().Format("20060102")
- hourStr := time.Now().Format("15") // 小时
- var filename string
-
- if sessionID == "" {
- // 全局事件按小时组织
- filename = fmt.Sprintf("stream-global-%s-%s.log", dateStr, hourStr)
- } else {
- // 会话事件按会话ID和日期组织
- filename = fmt.Sprintf("stream-session-%s-%s.log", sessionID, dateStr)
- }
-
- filepath := filepath.Join(logDir, filename)
-
- // 追加写入数据
- file, err := os.OpenFile(filepath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- fmt.Printf("🔍 [opencode-direct-client] 打开日志文件失败: %v\n", err)
- return
- }
- defer file.Close()
-
- // 写入时间戳和数据
- logLine := fmt.Sprintf("[%s] %s\n", time.Now().Format("15:04:05.000"), data)
- if _, err := file.WriteString(logLine); err != nil {
- fmt.Printf("🔍 [opencode-direct-client] 写入日志失败: %v\n", err)
- }
- }
|