mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-08-13 03:00:59 +00:00
feat(release): migrate GeekAI v4.3.0 to open source
- Sync backend and frontend from GeekAI Plus v4.3.0 - Remove commercial License flows and update open-source deployment defaults - Preserve Docker Compose deployment and bump image tags to v4.3.0 BREAKING CHANGE: commercial License configuration and related endpoints are removed
This commit is contained in:
+1
-1
@@ -115,7 +115,7 @@ func Ip2Region(searcher *xdb.Searcher, ip string) string {
|
||||
return fmt.Sprintf("%s-%s-%s", arr[0], arr[2], arr[3])
|
||||
}
|
||||
|
||||
func IsEmptyValue(obj interface{}) bool {
|
||||
func IsEmptyValue(obj any) bool {
|
||||
if obj == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
+63
-15
@@ -6,9 +6,11 @@ import (
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"io"
|
||||
"net/http"
|
||||
urlpkg "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-tika/tika"
|
||||
)
|
||||
@@ -84,23 +86,69 @@ func cleanBlankLine(content string) string {
|
||||
|
||||
// 下载文件
|
||||
func downloadFile(url string) (string, error) {
|
||||
base := filepath.Base(url)
|
||||
u, err := urlpkg.Parse(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
base := filepath.Base(u.Path)
|
||||
if base == "" || base == "." || base == "/" {
|
||||
base = "download"
|
||||
}
|
||||
dir := os.TempDir()
|
||||
filename := filepath.Join(dir, base)
|
||||
out, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// 获取数据
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
client := newHTTPClient(60*time.Second, "")
|
||||
retries := 2
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
// 每次重试都重新创建文件,避免写一半残留
|
||||
out, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 写入数据到文件
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return filename, err
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
_ = out.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
_ = out.Close()
|
||||
lastErr = err
|
||||
_ = os.Remove(filename)
|
||||
if attempt < retries && isRetryableError(err) {
|
||||
time.Sleep(retryDelay(attempt))
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
_ = resp.Body.Close()
|
||||
_ = out.Close()
|
||||
_ = os.Remove(filename)
|
||||
lastErr = fmt.Errorf("download file failed: status=%d", resp.StatusCode)
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
_, copyErr := io.Copy(out, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
_ = out.Close()
|
||||
if copyErr != nil {
|
||||
lastErr = copyErr
|
||||
_ = os.Remove(filename)
|
||||
if attempt < retries && isRetryableError(copyErr) {
|
||||
time.Sleep(retryDelay(attempt))
|
||||
continue
|
||||
}
|
||||
return "", copyErr
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
+178
-37
@@ -8,17 +8,174 @@ package utils
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/log"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
var logger = log.GetLogger()
|
||||
|
||||
const (
|
||||
defaultHTTPTimeout = 45 * time.Second
|
||||
defaultTLSHandshakeTimeout = 10 * time.Second
|
||||
defaultDialTimeout = 10 * time.Second
|
||||
defaultResponseHeaderTimeout = 30 * time.Second
|
||||
defaultIdleConnTimeout = 90 * time.Second
|
||||
defaultMaxIdleConns = 100
|
||||
defaultMaxIdleConnsPerHost = 10
|
||||
)
|
||||
|
||||
func newHTTPClient(timeout time.Duration, proxy string) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultHTTPTimeout
|
||||
}
|
||||
|
||||
var proxyFn func(*http.Request) (*url.URL, error)
|
||||
if strings.TrimSpace(proxy) != "" {
|
||||
if proxyURL, err := url.Parse(proxy); err == nil && proxyURL != nil {
|
||||
proxyFn = http.ProxyURL(proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
Proxy: proxyFn,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: defaultDialTimeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: defaultTLSHandshakeTimeout,
|
||||
ResponseHeaderTimeout: defaultResponseHeaderTimeout,
|
||||
IdleConnTimeout: defaultIdleConnTimeout,
|
||||
MaxIdleConns: defaultMaxIdleConns,
|
||||
MaxIdleConnsPerHost: defaultMaxIdleConnsPerHost,
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
func isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) {
|
||||
if ne.Timeout() || ne.Temporary() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容不同 Go/系统对握手超时的错误文本
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "tls handshake timeout") ||
|
||||
strings.Contains(msg, "handshake timeout") ||
|
||||
strings.Contains(msg, "connection reset by peer") ||
|
||||
strings.Contains(msg, "connection timed out")
|
||||
}
|
||||
|
||||
func retryDelay(attempt int) time.Duration {
|
||||
// attempt 从 0 开始:1s, 2s, 4s...
|
||||
d := time.Second * time.Duration(1<<attempt)
|
||||
if d > 8*time.Second {
|
||||
d = 8 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func readAllLimit(r io.Reader, maxBytes int64) ([]byte, error) {
|
||||
if maxBytes > 0 {
|
||||
return io.ReadAll(io.LimitReader(r, maxBytes))
|
||||
}
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
// FetchURLBytes 发起 GET 并返回响应体(只在 2xx 认为成功)。
|
||||
// 为了降低“偶发 HTTPS 握手超时”,对可重试错误/状态码会做有限重试。
|
||||
func FetchURLBytes(ctx context.Context, rawURL string, proxy string, timeout time.Duration, retries int, maxBytes int64) ([]byte, int, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultHTTPTimeout
|
||||
}
|
||||
if retries < 0 {
|
||||
retries = 0
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 8 << 20 // 默认最多读取 8MiB
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
if ctx.Err() != nil {
|
||||
return nil, 0, ctx.Err()
|
||||
}
|
||||
|
||||
client := newHTTPClient(timeout, proxy)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if attempt < retries && isRetryableError(err) {
|
||||
time.Sleep(retryDelay(attempt))
|
||||
continue
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
body, readErr := readAllLimit(resp.Body, maxBytes)
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
lastErr = readErr
|
||||
if attempt < retries && isRetryableError(readErr) {
|
||||
time.Sleep(retryDelay(attempt))
|
||||
continue
|
||||
}
|
||||
return nil, resp.StatusCode, readErr
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// 429/5xx 可能是短暂错误,允许有限重试
|
||||
status := resp.StatusCode
|
||||
lastErr = fmt.Errorf("request failed: status=%d", status)
|
||||
if attempt < retries && (status == http.StatusTooManyRequests || status >= 500 && status <= 599) {
|
||||
time.Sleep(retryDelay(attempt))
|
||||
continue
|
||||
}
|
||||
|
||||
// 避免把大 body 全塞进日志/错误里
|
||||
preview := strings.TrimSpace(string(body))
|
||||
if len(preview) > 256 {
|
||||
preview = preview[:256]
|
||||
}
|
||||
if preview != "" {
|
||||
return body, status, fmt.Errorf("request failed: status=%d body=%s", status, preview)
|
||||
}
|
||||
return body, status, lastErr
|
||||
}
|
||||
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
return nil, 0, lastErr
|
||||
}
|
||||
|
||||
// SendMsg 回复客户片段端消息
|
||||
func SendMsg(client *types.WsClient, message types.ReplyMessage) {
|
||||
@@ -54,36 +211,8 @@ func SendChannelMsg(ws *types.WsClient, channel types.WsChannel, message interfa
|
||||
}
|
||||
|
||||
func DownloadImage(imageURL string, proxy string) ([]byte, error) {
|
||||
var client *http.Client
|
||||
if proxy == "" {
|
||||
client = http.DefaultClient
|
||||
} else {
|
||||
proxyURL, _ := url.Parse(proxy)
|
||||
client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
},
|
||||
}
|
||||
}
|
||||
request, err := http.NewRequest("GET", imageURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
imageBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return imageBytes, nil
|
||||
body, _, err := FetchURLBytes(context.Background(), imageURL, proxy, defaultHTTPTimeout, 2, 32<<20)
|
||||
return body, err
|
||||
}
|
||||
|
||||
func GetBaseURL(strURL string) string {
|
||||
@@ -103,14 +232,26 @@ func GetImgExt(filename string) string {
|
||||
}
|
||||
|
||||
func GetFileSize(url string) (int64, error) {
|
||||
resp, err := http.Get(url)
|
||||
// 优先走 HEAD 获取 Content-Length,减少下载成本;若对方不支持,再 fallback 到 GET 统计字节数。
|
||||
client := newHTTPClient(defaultHTTPTimeout, "")
|
||||
if req, err := http.NewRequest(http.MethodHead, url, nil); err == nil {
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode <= 299 && resp.ContentLength > 0 {
|
||||
return resp.ContentLength, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// 尽量不把大 body 都读进来,只返回状态码
|
||||
return 0, fmt.Errorf("get file size failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
return int64(len(body)), nil
|
||||
n, err := io.Copy(io.Discard, resp.Body)
|
||||
return n, err
|
||||
}
|
||||
|
||||
+63
-5
@@ -14,6 +14,7 @@ import (
|
||||
"geekai/store/model"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
@@ -35,18 +36,63 @@ func CalcTokens(text string, model string) (int, error) {
|
||||
return len(token), nil
|
||||
}
|
||||
|
||||
// OpenAIResponse 非流式 chat/completions 响应;content 使用 RawMessage 以兼容 string 与多段式数组。
|
||||
type OpenAIResponse struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Index int `json:"index"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// assistantContentPart 兼容 OpenAI 多模态 / 多段文本:{"type":"text","text":"..."} 等。
|
||||
type assistantContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
// 少数网关使用 content 字段承载文本
|
||||
Inner string `json:"content"`
|
||||
}
|
||||
|
||||
// NormalizeAssistantContent 将 message.content 规范为纯文本。
|
||||
// 兼容:JSON string、null、OpenAI 数组段、以及单段对象。
|
||||
func NormalizeAssistantContent(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
var plain string
|
||||
if err := json.Unmarshal(raw, &plain); err == nil {
|
||||
return plain
|
||||
}
|
||||
var parts []assistantContentPart
|
||||
if err := json.Unmarshal(raw, &parts); err == nil && len(parts) > 0 {
|
||||
var b strings.Builder
|
||||
for _, p := range parts {
|
||||
switch {
|
||||
case p.Text != "":
|
||||
b.WriteString(p.Text)
|
||||
case p.Inner != "" && p.Type != "image_url" && p.Type != "image":
|
||||
b.WriteString(p.Inner)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
var one assistantContentPart
|
||||
if err := json.Unmarshal(raw, &one); err == nil {
|
||||
if one.Text != "" {
|
||||
return one.Text
|
||||
}
|
||||
if one.Inner != "" {
|
||||
return one.Inner
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func OpenAIRequest(db *gorm.DB, prompt string, modelId int) (string, error) {
|
||||
messages := make([]any, 1)
|
||||
messages[0] = types.Message{
|
||||
@@ -86,12 +132,16 @@ func SendOpenAIMessage(db *gorm.DB, messages []any, modelId int) (string, error)
|
||||
apiURL = apiKey.ApiURL
|
||||
}
|
||||
logger.Infof("Sending %s request, API KEY:%s, PROXY: %s, Model: %s", apiURL, apiKey.ApiURL, apiKey.ProxyURL, chatModel.Name)
|
||||
r, err := client.R().SetHeader("Body-Type", "application/json").
|
||||
r, err := client.R().
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("Authorization", "Bearer "+apiKey.Value).
|
||||
SetBody(types.ApiRequest{
|
||||
Model: chatModel.Value,
|
||||
Temperature: 0.9,
|
||||
MaxTokens: 1024,
|
||||
// gpt-5/o 系模型在复杂提示下可能先消耗大量 token 于推理过程,
|
||||
// 1024 容易导致 finish_reason=length 且 content 为空。
|
||||
MaxTokens: 4096,
|
||||
MaxCompletionTokens: 4096,
|
||||
Stream: false,
|
||||
Messages: messages,
|
||||
}).Post(apiURL)
|
||||
@@ -108,9 +158,17 @@ func SendOpenAIMessage(db *gorm.DB, messages []any, modelId int) (string, error)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
if len(response.Choices) == 0 {
|
||||
return "", fmt.Errorf("模型返回 choices 为空:%s", string(body))
|
||||
}
|
||||
|
||||
out := strings.TrimSpace(NormalizeAssistantContent(response.Choices[0].Message.Content))
|
||||
if out == "" {
|
||||
return "", fmt.Errorf("模型返回内容为空,请重试或更换模型(若使用推理模型,请确认网关已返回 content 或 reasoning_content)")
|
||||
}
|
||||
|
||||
// 更新 API KEY 的最后使用时间
|
||||
db.Model(&apiKey).UpdateColumn("last_used_at", time.Now().Unix())
|
||||
|
||||
return response.Choices[0].Message.Content, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user