feat: Phase 4 — LLM 集成,PR 描述生成与 AI 代码审查
This commit is contained in:
+152
@@ -0,0 +1,152 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
goopenai "github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// LLMConfig holds LLM API configuration.
|
||||
type LLMConfig struct {
|
||||
Endpoint string
|
||||
APIKey string
|
||||
Model string
|
||||
}
|
||||
|
||||
// GetLLMConfig reads LLM settings from the database.
|
||||
func GetLLMConfig(db *sql.DB) (LLMConfig, error) {
|
||||
config := LLMConfig{}
|
||||
|
||||
rows, err := db.Query(`SELECT key, value FROM settings WHERE key IN ('llm.endpoint', 'llm.api_key', 'llm.model')`)
|
||||
if err != nil {
|
||||
return config, fmt.Errorf("read settings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var key, value string
|
||||
if rows.Scan(&key, &value) == nil {
|
||||
switch key {
|
||||
case "llm.endpoint":
|
||||
config.Endpoint = value
|
||||
case "llm.api_key":
|
||||
config.APIKey = value
|
||||
case "llm.model":
|
||||
config.Model = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.APIKey == "" {
|
||||
return config, fmt.Errorf("LLM API key not configured — please set it in the settings page")
|
||||
}
|
||||
|
||||
if config.Model == "" {
|
||||
config.Model = "gpt-4o"
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// StreamCallback is called for each SSE event during LLM streaming.
|
||||
type StreamCallback func(event string, data interface{})
|
||||
|
||||
// ChatStream sends a streaming chat completion request to an OpenAI-compatible API.
|
||||
// It calls callback with "content" events for each chunk received.
|
||||
// Returns the full concatenated response text.
|
||||
func ChatStream(config LLMConfig, messages []goopenai.ChatCompletionMessage, callback StreamCallback) (string, error) {
|
||||
clientConfig := goopenai.DefaultConfig(config.APIKey)
|
||||
if config.Endpoint != "" {
|
||||
clientConfig.BaseURL = config.Endpoint
|
||||
}
|
||||
client := goopenai.NewClientWithConfig(clientConfig)
|
||||
|
||||
ctx := context.Background()
|
||||
stream, err := client.CreateChatCompletionStream(ctx, goopenai.ChatCompletionRequest{
|
||||
Model: config.Model,
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create stream: %w", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
var fullResponse strings.Builder
|
||||
for {
|
||||
response, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fullResponse.String(), fmt.Errorf("stream recv: %w", err)
|
||||
}
|
||||
if len(response.Choices) > 0 {
|
||||
content := response.Choices[0].Delta.Content
|
||||
if content != "" {
|
||||
fullResponse.WriteString(content)
|
||||
if callback != nil {
|
||||
callback("content", map[string]interface{}{"content": content})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fullResponse.String(), nil
|
||||
}
|
||||
|
||||
// extractJSON attempts to extract a JSON object or array from a string
|
||||
// that may contain markdown code blocks or extra text.
|
||||
func extractJSON(s string) string {
|
||||
// Try to find JSON in markdown code block
|
||||
if idx := strings.Index(s, "```json"); idx >= 0 {
|
||||
start := idx + 7
|
||||
if end := strings.Index(s[start:], "```"); end >= 0 {
|
||||
return strings.TrimSpace(s[start : start+end])
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(s, "```"); idx >= 0 {
|
||||
start := idx + 3
|
||||
if nl := strings.Index(s[start:], "\n"); nl >= 0 {
|
||||
start += nl + 1
|
||||
}
|
||||
if end := strings.Index(s[start:], "```"); end >= 0 {
|
||||
return strings.TrimSpace(s[start : start+end])
|
||||
}
|
||||
}
|
||||
|
||||
// Find first { or [
|
||||
startObj := strings.Index(s, "{")
|
||||
startArr := strings.Index(s, "[")
|
||||
|
||||
var start int
|
||||
var endChar byte
|
||||
if startObj >= 0 && (startArr < 0 || startObj < startArr) {
|
||||
start = startObj
|
||||
endChar = '}'
|
||||
} else if startArr >= 0 {
|
||||
start = startArr
|
||||
endChar = ']'
|
||||
} else {
|
||||
return s
|
||||
}
|
||||
|
||||
// Find matching closing bracket
|
||||
depth := 0
|
||||
for i := start; i < len(s); i++ {
|
||||
if s[i] == '{' || s[i] == '[' {
|
||||
depth++
|
||||
} else if s[i] == '}' || s[i] == ']' {
|
||||
depth--
|
||||
if depth == 0 && s[i] == endChar {
|
||||
return s[start : i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s[start:]
|
||||
}
|
||||
Reference in New Issue
Block a user