init: 毛孩子计划 小程序 + Go 后端 + 内嵌后台
- pets-fe: 微信原生小程序(首页/计划/记录/报告/社区/引导), 服务端驱动、无假数据;弹层改用 scroll-view,打开时隐藏自定义 tabBar - pets-be: Gin + GORM(MySQL, sundynix_ 前缀) + MinIO,统一响应/分页, 微信 code2session 登录,provider-neutral AI(DeepSeek),go:embed React 后台 - 修复:分段选择类型不匹配(字符串 vs 数字)导致选不中 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
// Package ai 提供 provider 中立的大模型调用能力。
|
||||
// 目前内置 openai 兼容 provider(适配 DeepSeek / Qwen 兼容模式 / Gemini 兼容端点等),
|
||||
// 以及规则化 mock。新增其它厂商只需实现 Provider 接口。
|
||||
package ai
|
||||
|
||||
import (
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
// Message 一条对话消息
|
||||
type Message struct {
|
||||
Role string // system / user / assistant
|
||||
Content string
|
||||
}
|
||||
|
||||
// Options 单次调用参数
|
||||
type Options struct {
|
||||
JSON bool // 要求返回 JSON(结构化输出)
|
||||
MaxTokens int // 0 用配置默认
|
||||
Temperature float64 // <0 用配置默认
|
||||
}
|
||||
|
||||
// Provider 大模型供应商接口
|
||||
type Provider interface {
|
||||
Name() string
|
||||
// Complete 传入 system 提示与多轮消息,返回助手回复文本
|
||||
Complete(system string, messages []Message, opts Options) (string, error)
|
||||
}
|
||||
|
||||
// Engine 对外统一入口,持有当前 provider 与开关
|
||||
type Engine struct {
|
||||
provider Provider
|
||||
cfg config.AIConfig
|
||||
}
|
||||
|
||||
// New 根据配置构建引擎。未启用或缺 key 时 Enabled()=false,业务侧回退规则化文案。
|
||||
func New(cfg config.AIConfig) *Engine {
|
||||
e := &Engine{cfg: cfg}
|
||||
if !cfg.Enabled || cfg.APIKey == "" {
|
||||
return e
|
||||
}
|
||||
switch cfg.Provider {
|
||||
case "", "openai":
|
||||
e.provider = newOpenAICompatProvider(cfg)
|
||||
default:
|
||||
// 未知 provider 视为未启用,回退规则化
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Enabled 是否可用真实模型
|
||||
func (e *Engine) Enabled() bool {
|
||||
return e.provider != nil
|
||||
}
|
||||
|
||||
// ProviderName 当前供应商名(诊断用)
|
||||
func (e *Engine) ProviderName() string {
|
||||
if e.provider == nil {
|
||||
return "disabled"
|
||||
}
|
||||
return e.provider.Name()
|
||||
}
|
||||
|
||||
// Complete 代理到当前 provider(调用前请先判断 Enabled)
|
||||
func (e *Engine) Complete(system string, messages []Message, opts Options) (string, error) {
|
||||
if opts.MaxTokens == 0 {
|
||||
opts.MaxTokens = e.cfg.MaxTokens
|
||||
}
|
||||
if opts.Temperature < 0 {
|
||||
opts.Temperature = e.cfg.Temperature
|
||||
}
|
||||
return e.provider.Complete(system, messages, opts)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
// openaiCompatProvider 走 OpenAI 兼容的 /chat/completions 协议。
|
||||
// DeepSeek、Qwen(dashscope 兼容模式)、Moonshot、Gemini(OpenAI 兼容端点) 等均可用。
|
||||
type openaiCompatProvider struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newOpenAICompatProvider(cfg config.AIConfig) *openaiCompatProvider {
|
||||
timeout := time.Duration(cfg.TimeoutSec) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &openaiCompatProvider{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *openaiCompatProvider) Name() string { return "openai-compat:" + p.model }
|
||||
|
||||
type chatReq struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMsg `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
ResponseFormat *respFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type chatMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type respFormat struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (p *openaiCompatProvider) Complete(system string, messages []Message, opts Options) (string, error) {
|
||||
msgs := make([]chatMsg, 0, len(messages)+1)
|
||||
if system != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "system", Content: system})
|
||||
}
|
||||
for _, m := range messages {
|
||||
msgs = append(msgs, chatMsg{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
|
||||
body := chatReq{
|
||||
Model: p.model,
|
||||
Messages: msgs,
|
||||
Temperature: opts.Temperature,
|
||||
MaxTokens: opts.MaxTokens,
|
||||
}
|
||||
if opts.JSON {
|
||||
body.ResponseFormat = &respFormat{Type: "json_object"}
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.client.Timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
var out chatResp
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return "", fmt.Errorf("ai 响应解析失败: %s", truncate(string(data), 200))
|
||||
}
|
||||
if out.Error != nil {
|
||||
return "", fmt.Errorf("ai 供应商错误: %s", out.Error.Message)
|
||||
}
|
||||
if resp.StatusCode >= 400 || len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("ai 调用失败(%d): %s", resp.StatusCode, truncate(string(data), 200))
|
||||
}
|
||||
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
Reference in New Issue
Block a user