You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
294 lines
7.0 KiB
294 lines
7.0 KiB
// Package sparkchat 是星火文本 websocket。会议总结不要用它。
|
|
//
|
|
// 海外 SDK 的文本模型是 domain=openai,见 sys/iflytek/openaichat。
|
|
// 这个包留着只因为星火协议和 openai 那条不是同一个地址。
|
|
package sparkchat
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultHost = "spark-api.xf-yun.com"
|
|
defaultDomain = "generalv3.5"
|
|
)
|
|
|
|
// Message 一轮对话。Role 取 system / user / assistant。
|
|
type Message struct {
|
|
Role string
|
|
Content string
|
|
}
|
|
|
|
// Client 星火文本客户端。
|
|
type Client struct {
|
|
AppID string
|
|
APIKey string
|
|
Secret string
|
|
Host string
|
|
Domain string
|
|
}
|
|
|
|
// New 用 SparkChain 凭据建客户端。
|
|
func New(appID, apiKey, secret, host, domain string) (*Client, error) {
|
|
if strings.TrimSpace(appID) == "" || strings.TrimSpace(apiKey) == "" || strings.TrimSpace(secret) == "" {
|
|
return nil, fmt.Errorf("iflytek spark: app_id/api_key/api_secret 都不能空")
|
|
}
|
|
host = strings.TrimSpace(host)
|
|
if host == "" {
|
|
host = defaultHost
|
|
}
|
|
domain = strings.TrimSpace(domain)
|
|
if domain == "" {
|
|
domain = defaultDomain
|
|
}
|
|
return &Client{AppID: appID, APIKey: apiKey, Secret: secret, Host: host, Domain: domain}, nil
|
|
}
|
|
|
|
// Chat 同步要一段回复。多帧内容拼在一起返回。
|
|
func (c *Client) Chat(messages []Message) (string, error) {
|
|
if len(messages) == 0 {
|
|
return "", fmt.Errorf("iflytek spark: 没有输入")
|
|
}
|
|
path := domainPath(c.Domain)
|
|
conn, err := c.dial(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(90 * time.Second))
|
|
|
|
text := make([]map[string]string, 0, len(messages))
|
|
for _, m := range messages {
|
|
role := m.Role
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
text = append(text, map[string]string{"role": role, "content": m.Content})
|
|
}
|
|
payload := map[string]any{
|
|
"header": map[string]string{"app_id": c.AppID, "uid": "echomeet"},
|
|
"parameter": map[string]any{
|
|
"chat": map[string]any{
|
|
"domain": c.Domain,
|
|
"temperature": 0.5,
|
|
"max_tokens": 4096,
|
|
},
|
|
},
|
|
"payload": map[string]any{
|
|
"message": map[string]any{"text": text},
|
|
},
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := writeText(conn, raw); err != nil {
|
|
return "", err
|
|
}
|
|
var b strings.Builder
|
|
for {
|
|
frame, err := readFrame(conn)
|
|
if err != nil {
|
|
if b.Len() > 0 && err == io.EOF {
|
|
break
|
|
}
|
|
return "", err
|
|
}
|
|
if frame == nil {
|
|
continue
|
|
}
|
|
var resp struct {
|
|
Header struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Status int `json:"status"`
|
|
} `json:"header"`
|
|
Payload struct {
|
|
Choices struct {
|
|
Status int `json:"status"`
|
|
Text []struct {
|
|
Content string `json:"content"`
|
|
} `json:"text"`
|
|
} `json:"choices"`
|
|
} `json:"payload"`
|
|
}
|
|
if json.Unmarshal(frame, &resp) != nil {
|
|
continue
|
|
}
|
|
if resp.Header.Code != 0 {
|
|
return "", fmt.Errorf("iflytek spark: code=%d %s", resp.Header.Code, resp.Header.Message)
|
|
}
|
|
for _, t := range resp.Payload.Choices.Text {
|
|
b.WriteString(t.Content)
|
|
}
|
|
if resp.Header.Status == 2 || resp.Payload.Choices.Status == 2 {
|
|
break
|
|
}
|
|
}
|
|
out := strings.TrimSpace(b.String())
|
|
if out == "" {
|
|
return "", fmt.Errorf("iflytek spark: 空回复")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func domainPath(domain string) string {
|
|
switch domain {
|
|
case "general":
|
|
return "/v1.1/chat"
|
|
case "generalv2":
|
|
return "/v2.1/chat"
|
|
case "generalv3":
|
|
return "/v3.1/chat"
|
|
case "4.0Ultra":
|
|
return "/v4.0/chat"
|
|
default:
|
|
return "/v3.5/chat"
|
|
}
|
|
}
|
|
|
|
func (c *Client) dial(path string) (net.Conn, error) {
|
|
date := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT")
|
|
origin := "host: " + c.Host + "\n" + "date: " + date + "\n" + "GET " + path + " HTTP/1.1"
|
|
mac := hmac.New(sha256.New, []byte(c.Secret))
|
|
_, _ = mac.Write([]byte(origin))
|
|
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
authOrigin := fmt.Sprintf(`api_key="%s", algorithm="hmac-sha256", headers="host date request-line", signature="%s"`,
|
|
c.APIKey, signature)
|
|
auth := base64.StdEncoding.EncodeToString([]byte(authOrigin))
|
|
q := url.Values{}
|
|
q.Set("authorization", auth)
|
|
q.Set("date", date)
|
|
q.Set("host", c.Host)
|
|
conn, err := tls.Dial("tcp", c.Host+":443", &tls.Config{ServerName: c.Host})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iflytek spark dial: %w", err)
|
|
}
|
|
key := make([]byte, 16)
|
|
_, _ = rand.Read(key)
|
|
secKey := base64.StdEncoding.EncodeToString(key)
|
|
req := "GET " + path + "?" + q.Encode() + " HTTP/1.1\r\n" +
|
|
"Host: " + c.Host + "\r\n" +
|
|
"Upgrade: websocket\r\n" +
|
|
"Connection: Upgrade\r\n" +
|
|
"Sec-WebSocket-Key: " + secKey + "\r\n" +
|
|
"Sec-WebSocket-Version: 13\r\n\r\n"
|
|
if _, err := io.WriteString(conn, req); err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
br := bufio.NewReader(conn)
|
|
status, err := br.ReadString('\n')
|
|
if err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
if !strings.Contains(status, "101") {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("iflytek spark handshake: %s", strings.TrimSpace(status))
|
|
}
|
|
for {
|
|
line, err := br.ReadString('\n')
|
|
if err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
if line == "\r\n" || line == "\n" {
|
|
break
|
|
}
|
|
}
|
|
return &bufferedConn{Conn: conn, r: br}, nil
|
|
}
|
|
|
|
// bufferedConn 把握手时多读进 bufio 的字节交还给后续读帧。
|
|
type bufferedConn struct {
|
|
net.Conn
|
|
r *bufio.Reader
|
|
}
|
|
|
|
func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
|
|
|
func writeText(conn net.Conn, payload []byte) error {
|
|
mask := make([]byte, 4)
|
|
_, _ = rand.Read(mask)
|
|
var hdr []byte
|
|
n := len(payload)
|
|
switch {
|
|
case n < 126:
|
|
hdr = []byte{0x81, byte(n) | 0x80}
|
|
case n < 65536:
|
|
hdr = []byte{0x81, 126 | 0x80, byte(n >> 8), byte(n)}
|
|
default:
|
|
return fmt.Errorf("iflytek spark: 请求过长")
|
|
}
|
|
frame := append(hdr, mask...)
|
|
masked := make([]byte, n)
|
|
for i := 0; i < n; i++ {
|
|
masked[i] = payload[i] ^ mask[i%4]
|
|
}
|
|
frame = append(frame, masked...)
|
|
_, err := conn.Write(frame)
|
|
return err
|
|
}
|
|
|
|
func readFrame(conn net.Conn) ([]byte, error) {
|
|
for {
|
|
hdr := make([]byte, 2)
|
|
if _, err := io.ReadFull(conn, hdr); err != nil {
|
|
return nil, err
|
|
}
|
|
opcode := hdr[0] & 0x0f
|
|
masked := hdr[1]&0x80 != 0
|
|
n := int(hdr[1] & 0x7f)
|
|
switch n {
|
|
case 126:
|
|
ext := make([]byte, 2)
|
|
if _, err := io.ReadFull(conn, ext); err != nil {
|
|
return nil, err
|
|
}
|
|
n = int(ext[0])<<8 | int(ext[1])
|
|
case 127:
|
|
return nil, fmt.Errorf("iflytek spark: 帧过长")
|
|
}
|
|
var mask []byte
|
|
if masked {
|
|
mask = make([]byte, 4)
|
|
if _, err := io.ReadFull(conn, mask); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
payload := make([]byte, n)
|
|
if _, err := io.ReadFull(conn, payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if masked {
|
|
for i := 0; i < n; i++ {
|
|
payload[i] ^= mask[i%4]
|
|
}
|
|
}
|
|
switch opcode {
|
|
case 0x1, 0x0:
|
|
return payload, nil
|
|
case 0x8:
|
|
return nil, io.EOF
|
|
case 0x9: // ping
|
|
pong := []byte{0x8A, 0}
|
|
_, _ = conn.Write(pong)
|
|
continue
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|