feat: add coze channel (#365)

This commit is contained in:
Sh1n3zZ 2025-04-29 19:12:52 +08:00
parent 628b0ba8d2
commit f787f3513a
No known key found for this signature in database
GPG Key ID: 696702CF723B0452
8 changed files with 552 additions and 4 deletions

View File

@ -6,6 +6,7 @@ import (
"chat/adapter/bing" "chat/adapter/bing"
"chat/adapter/claude" "chat/adapter/claude"
adaptercommon "chat/adapter/common" adaptercommon "chat/adapter/common"
"chat/adapter/coze"
"chat/adapter/dashscope" "chat/adapter/dashscope"
"chat/adapter/deepseek" "chat/adapter/deepseek"
"chat/adapter/dify" "chat/adapter/dify"
@ -39,6 +40,7 @@ var channelFactories = map[string]adaptercommon.FactoryCreator{
globals.MidjourneyChannelType: midjourney.NewChatInstanceFromConfig, globals.MidjourneyChannelType: midjourney.NewChatInstanceFromConfig,
globals.DeepseekChannelType: deepseek.NewChatInstanceFromConfig, globals.DeepseekChannelType: deepseek.NewChatInstanceFromConfig,
globals.DifyChannelType: dify.NewChatInstanceFromConfig, globals.DifyChannelType: dify.NewChatInstanceFromConfig,
globals.CozeChannelType: coze.NewChatInstanceFromConfig,
globals.MoonshotChannelType: openai.NewChatInstanceFromConfig, // openai format globals.MoonshotChannelType: openai.NewChatInstanceFromConfig, // openai format
globals.GroqChannelType: openai.NewChatInstanceFromConfig, // openai format globals.GroqChannelType: openai.NewChatInstanceFromConfig, // openai format

204
adapter/coze/chat.go Normal file
View File

@ -0,0 +1,204 @@
package coze
import (
adaptercommon "chat/adapter/common"
"chat/globals"
"chat/utils"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
)
type ChatInstance struct {
Endpoint string
ApiKey string
AutoSaveHistory bool
responseComplete bool
}
func (c *ChatInstance) GetEndpoint() string {
return c.Endpoint
}
func (c *ChatInstance) GetApiKey() string {
return c.ApiKey
}
func (c *ChatInstance) GetHeader() map[string]string {
return map[string]string{
"Content-Type": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", c.GetApiKey()),
}
}
func NewChatInstance(endpoint, apiKey string) *ChatInstance {
return &ChatInstance{
Endpoint: endpoint,
ApiKey: apiKey,
AutoSaveHistory: false,
}
}
func NewChatInstanceFromConfig(conf globals.ChannelConfig) adaptercommon.Factory {
return NewChatInstance(
conf.GetEndpoint(),
conf.GetRandomSecret(),
)
}
func (c *ChatInstance) GetChatEndpoint() string {
return fmt.Sprintf("%s/v3/chat", c.GetEndpoint())
}
func (c *ChatInstance) GetChatBody(props *adaptercommon.ChatProps, stream bool) interface{} {
additionalMessages := []EnterMessage{}
for _, msg := range props.Message {
enterMsg := EnterMessage{
Role: msg.Role,
Content: msg.Content,
ContentType: "text",
}
if msg.Role == "user" {
enterMsg.Type = "question"
} else if msg.Role == "assistant" {
enterMsg.Type = "answer"
}
additionalMessages = append(additionalMessages, enterMsg)
}
// `user_id` is required in coze
timestamp := time.Now().UnixNano()
userID := fmt.Sprintf("user_%d", timestamp)
return ChatRequest{
BotID: props.Model,
UserID: userID,
AdditionalMessages: additionalMessages,
Stream: stream,
AutoSaveHistory: c.AutoSaveHistory,
}
}
func (c *ChatInstance) ProcessLine(data string) (string, error) {
if c.responseComplete {
return "", nil
}
if data == "" {
return "", nil
}
chunk, complete, err := processStreamResponse(data)
if err != nil {
return "", err
}
if complete {
c.responseComplete = true
}
return chunk.Content, nil
}
func (c *ChatInstance) CreateChatRequest(props *adaptercommon.ChatProps) (string, error) {
// TODO: use standard non-stream request
c.AutoSaveHistory = true
res, err := utils.Post(
c.GetChatEndpoint(),
c.GetHeader(),
c.GetChatBody(props, false),
props.Proxy,
)
if err != nil || res == nil {
return "", fmt.Errorf("coze error: %s", err.Error())
}
responseBody := utils.Marshal(res)
response := processChatResponse(responseBody)
if response == nil {
return "", fmt.Errorf("coze error: cannot parse response")
}
if response.Code != 0 {
return "", fmt.Errorf("coze error: %s (code: %d)", response.Msg, response.Code)
}
var responseContent string
var responseMutex sync.Mutex
err = c.CreateStreamChatRequest(props, func(chunk *globals.Chunk) error {
responseMutex.Lock()
defer responseMutex.Unlock()
responseContent += chunk.Content
return nil
})
if err != nil {
return "", err
}
if responseContent == "" {
return "", fmt.Errorf("coze error: empty response from API")
}
return responseContent, nil
}
func (c *ChatInstance) CreateStreamChatRequest(props *adaptercommon.ChatProps, callback globals.Hook) error {
c.responseComplete = false
c.AutoSaveHistory = false
err := utils.EventScanner(&utils.EventScannerProps{
Method: "POST",
Uri: c.GetChatEndpoint(),
Headers: c.GetHeader(),
Body: c.GetChatBody(props, true),
FullSSE: true,
Callback: func(data string) error {
partial, err := c.ProcessLine(data)
if err != nil {
return err
}
if partial != "" {
err = callback(&globals.Chunk{Content: partial})
if err != nil {
return err
}
}
return nil
},
}, props.Proxy)
c.responseComplete = true
if err != nil {
if strings.Contains(err.Body, "\"code\":") {
errorResp := processChatErrorResponse(err.Body)
if errorResp != nil && errorResp.Data.Code != 0 {
return errors.New(fmt.Sprintf("coze error: %s (code: %d)", errorResp.Data.Msg, errorResp.Data.Code))
}
var genericResp map[string]interface{}
if jsonErr := json.Unmarshal([]byte(err.Body), &genericResp); jsonErr == nil {
errMsg, _ := json.Marshal(genericResp)
return errors.New(fmt.Sprintf("coze error: %s", string(errMsg)))
}
}
if err.Error != nil {
return err.Error
}
return errors.New(fmt.Sprintf("coze error: unexpected error in stream request"))
}
return nil
}

147
adapter/coze/processor.go Normal file
View File

@ -0,0 +1,147 @@
package coze
import (
"chat/globals"
"chat/utils"
"errors"
"fmt"
"strconv"
"strings"
)
func processChatResponse(data string) *ChatResponse {
if form := utils.UnmarshalForm[ChatResponse](data); form != nil {
return form
}
return nil
}
func processChatStreamResponse(data string) *ChatStreamResponse {
if form := utils.UnmarshalForm[ChatStreamResponse](data); form != nil {
return form
}
return nil
}
func processChatStreamData(data string) *ChatStreamData {
if form := utils.UnmarshalForm[ChatStreamData](data); form != nil {
return form
}
return nil
}
func processChatErrorResponse(data string) *ChatStreamErrorResponse {
if form := utils.UnmarshalForm[ChatStreamErrorResponse](data); form != nil {
return form
}
return nil
}
func processSSEData(data string) (event string, eventData string, err error) {
if data == "" {
return "", "", nil
}
sseLines := strings.Split(data, "\n")
for _, line := range sseLines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "event:") {
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
eventData = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
if eventData == "" {
return "", "", nil
}
if strings.HasPrefix(eventData, "\"") && strings.HasSuffix(eventData, "\"") && len(eventData) > 2 {
unquoted, err := strconv.Unquote(eventData)
if err == nil {
eventData = unquoted
}
}
return event, eventData, nil
}
func processEventContent(event string, eventData string) (content string, complete bool, err error) {
switch event {
case "conversation.message.delta":
content, _ := parseEventContent(event, eventData)
if content != "" {
return content, false, nil
}
streamData := processChatStreamData(eventData)
if streamData != nil && streamData.Type == "answer" && streamData.Role == "assistant" && streamData.Content != "" {
return streamData.Content, false, nil
}
case "conversation.message.completed":
return "", false, nil
case "conversation.chat.completed":
return "", true, nil
case "conversation.chat.failed":
streamData := processChatStreamData(eventData)
if streamData != nil {
if streamData.Code != 0 && streamData.Msg != "" {
return "", false, errors.New(fmt.Sprintf("coze error: %s (code: %d)", streamData.Msg, streamData.Code))
}
}
return "", false, errors.New("coze error: conversation failed")
case "done":
return "", true, nil
}
errorResp := processChatErrorResponse(eventData)
if errorResp != nil && errorResp.Data.Code != 0 {
return "", false, errors.New(fmt.Sprintf("coze error: %s (code: %d)", errorResp.Data.Msg, errorResp.Data.Code))
}
streamData := processChatStreamData(eventData)
if streamData != nil {
if streamData.Code != 0 && streamData.Msg != "" {
return "", false, errors.New(fmt.Sprintf("coze error: %s (code: %d)", streamData.Msg, streamData.Code))
}
if streamData.LastError.Code != 0 && streamData.LastError.Msg != "" {
return "", false, errors.New(fmt.Sprintf("coze error: %s (code: %d)", streamData.LastError.Msg, streamData.LastError.Code))
}
}
return "", false, nil
}
func parseEventContent(eventType string, eventData string) (string, error) {
if eventType == "conversation.message.delta" {
streamResp := processChatStreamResponse(fmt.Sprintf(`{"event":"%s","data":%s}`, eventType, eventData))
if streamResp != nil {
streamData := processChatStreamData(streamResp.Data)
if streamData != nil && streamData.Type == "answer" && streamData.Role == "assistant" && streamData.Content != "" {
return streamData.Content, nil
}
}
}
return "", nil
}
func processStreamResponse(data string) (*globals.Chunk, bool, error) {
event, eventData, err := processSSEData(data)
if err != nil {
return nil, false, err
}
if event == "" || eventData == "" {
return &globals.Chunk{Content: ""}, false, nil
}
content, complete, err := processEventContent(event, eventData)
if err != nil {
return nil, false, err
}
return &globals.Chunk{
Content: content,
}, complete, nil
}

98
adapter/coze/struct.go Normal file
View File

@ -0,0 +1,98 @@
package coze
type ChatRequest struct {
BotID string `json:"bot_id"`
UserID string `json:"user_id"`
AdditionalMessages []EnterMessage `json:"additional_messages,omitempty"`
Stream bool `json:"stream"`
CustomVariables map[string]string `json:"custom_variables,omitempty"`
AutoSaveHistory bool `json:"auto_save_history"`
MetaData map[string]string `json:"meta_data,omitempty"`
ExtraParams map[string]string `json:"extra_params,omitempty"`
ShortcutCommand *ShortcutCommand `json:"shortcut_command,omitempty"`
}
type EnterMessage struct {
Role string `json:"role"`
Type string `json:"type,omitempty"`
Content string `json:"content,omitempty"`
ContentType string `json:"content_type,omitempty"`
MetaData map[string]string `json:"meta_data,omitempty"`
}
type ShortcutCommand struct {
// TODO: support for adding this on demand
}
type ObjectString struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
FileID string `json:"file_id,omitempty"`
FileURL string `json:"file_url,omitempty"`
}
type ChatResponse struct {
Data struct {
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
BotID string `json:"bot_id"`
CreatedAt int64 `json:"created_at"`
CompletedAt int64 `json:"completed_at"`
LastError interface{} `json:"last_error"`
MetaData map[string]string `json:"meta_data"`
Status string `json:"status"`
Usage *Usage `json:"usage"`
} `json:"data"`
Code int `json:"code"`
Msg string `json:"msg"`
}
type Usage struct {
TokenCount int `json:"token_count"`
OutputTokens int `json:"output_tokens"`
InputTokens int `json:"input_tokens"`
}
type ChatStreamResponse struct {
Event string `json:"event"`
Data string `json:"data"`
}
type ChatStreamData struct {
ID string `json:"id,omitempty"`
Role string `json:"role,omitempty"`
Type string `json:"type,omitempty"`
Content string `json:"content,omitempty"`
ContentType string `json:"content_type,omitempty"`
ChatID string `json:"chat_id,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
BotID string `json:"bot_id,omitempty"`
SectionID string `json:"section_id,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
CompletedAt int64 `json:"completed_at,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
Status string `json:"status,omitempty"`
LastError struct {
Code int `json:"code"`
Msg string `json:"msg"`
} `json:"last_error,omitempty"`
Code int `json:"code"`
Msg string `json:"msg"`
Usage *Usage `json:"usage,omitempty"`
MetaData map[string]string `json:"meta_data,omitempty"`
FromModule interface{} `json:"from_module,omitempty"`
FromUnit interface{} `json:"from_unit,omitempty"`
}
type ChatStreamErrorResponse struct {
Event string `json:"event"`
Data struct {
Code int `json:"code"`
Msg string `json:"msg"`
} `json:"data"`
}

View File

@ -68,6 +68,7 @@ export const ChannelTypes: Record<string, string> = {
slack: "Slack Claude", slack: "Slack Claude",
deepseek: "深度求索 DeepSeek", deepseek: "深度求索 DeepSeek",
dify: "Dify", dify: "Dify",
coze: "扣子 Coze",
}; };
export const ShortChannelTypes: Record<string, string> = { export const ShortChannelTypes: Record<string, string> = {
@ -89,6 +90,7 @@ export const ShortChannelTypes: Record<string, string> = {
slack: "Slack", slack: "Slack",
deepseek: "深度求索", deepseek: "深度求索",
dify: "Dify", dify: "Dify",
coze: "Coze",
}; };
export const ChannelInfos: Record<string, ChannelInfo> = { export const ChannelInfos: Record<string, ChannelInfo> = {
@ -297,6 +299,17 @@ export const ChannelInfos: Record<string, ChannelInfo> = {
"> 因此,您需要为每一个 Dify 平台的 CHATFLOW 分别创建渠道 \n" + "> 因此,您需要为每一个 Dify 平台的 CHATFLOW 分别创建渠道 \n" +
"> 如果需要让系统自动适配 Dify 平台的图标(商业版 / Pro请将模型名称填写为 **dify** 开头的模型,如 **dify-chat** \n", "> 如果需要让系统自动适配 Dify 平台的图标(商业版 / Pro请将模型名称填写为 **dify** 开头的模型,如 **dify-chat** \n",
}, },
coze: {
endpoint: "https://api.coze.cn",
format: "<api-key>",
models: [""],
description:
"> 扣子 Coze 的模型名称即为 Coze 平台的 **bot_id** \n" +
"> 进入智能体的开发页面,开发页面 URL 中 bot 参数后的数字就是智能体 ID \n" +
"> 例如 [https://www.coze.cn/space/341****/bot/73428668*****](https://www.coze.cn/space/341****/bot/73428668*****),智能体 ID 为 73428668***** \n" +
"> 确保当前使用的访问密钥已被授予智能体所属空间的 chat 权限 \n" +
"> 如果需要让系统自动适配扣子 Coze 平台的图标(商业版 / Pro请在 **模型映射** 中将 **bot_id** 映射为 **coze** 开头的模型,如 coze-chat>73428668***** \n",
},
}; };
export const defaultChannelModels: string[] = getUniqueList( export const defaultChannelModels: string[] = getUniqueList(

View File

@ -76,7 +76,7 @@ export const modelColorMapper: Record<string, string> = {
// ByteDance Skylark / Doubao / Coze // ByteDance Skylark / Doubao / Coze
skylark: "sky-300", skylark: "sky-300",
doubao: "sky-300", doubao: "sky-300",
coze: "sky-300", coze: "indigo-400",
// Dify // Dify
dify: "gray-300", dify: "gray-300",

View File

@ -27,6 +27,7 @@ const (
GroqChannelType = "groq" GroqChannelType = "groq"
DeepseekChannelType = "deepseek" DeepseekChannelType = "deepseek"
DifyChannelType = "dify" DifyChannelType = "dify"
CozeChannelType = "coze"
) )
const ( const (

View File

@ -17,6 +17,7 @@ type EventScannerProps struct {
Headers map[string]string Headers map[string]string
Body interface{} Body interface{}
Callback func(string) error Callback func(string) error
FullSSE bool
} }
type EventScannerError struct { type EventScannerError struct {
@ -85,7 +86,89 @@ func EventScanner(props *EventScannerProps, config ...globals.ProxyConfig) *Even
} }
} }
scanner := bufio.NewScanner(resp.Body) if props.FullSSE {
return processFullSSE(resp.Body, props.Callback)
}
return processLegacySSE(resp.Body, props.Callback)
}
func processFullSSE(body io.ReadCloser, callback func(string) error) *EventScannerError {
scanner := bufio.NewScanner(body)
var eventType, eventData string
var buffer strings.Builder
for scanner.Scan() {
line := scanner.Text()
if len(strings.TrimSpace(line)) == 0 {
if eventData != "" {
if eventType != "" {
buffer.WriteString("event: ")
buffer.WriteString(eventType)
buffer.WriteString("\n")
}
buffer.WriteString("data: ")
buffer.WriteString(eventData)
eventStr := buffer.String()
if globals.DebugMode {
globals.Debug(fmt.Sprintf("[sse-full] event: %s", eventStr))
}
if err := callback(eventStr); err != nil {
err := body.Close()
if err != nil {
globals.Debug(fmt.Sprintf("[sse] event source close error: %s", err.Error()))
}
return &EventScannerError{Error: err}
}
eventType = ""
eventData = ""
buffer.Reset()
}
continue
}
if strings.HasPrefix(line, "event:") {
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
continue
}
if strings.HasPrefix(line, "data:") {
eventData = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if eventData == "[DONE]" || strings.HasPrefix(eventData, "[DONE]") {
continue
}
}
}
if eventData != "" {
if eventType != "" {
buffer.WriteString("event: ")
buffer.WriteString(eventType)
buffer.WriteString("\n")
}
buffer.WriteString("data: ")
buffer.WriteString(eventData)
eventStr := buffer.String()
if globals.DebugMode {
globals.Debug(fmt.Sprintf("[sse-full] last event: %s", eventStr))
}
if err := callback(eventStr); err != nil {
return &EventScannerError{Error: err}
}
}
return nil
}
func processLegacySSE(body io.ReadCloser, callback func(string) error) *EventScannerError {
scanner := bufio.NewScanner(body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 { if atEOF && len(data) == 0 {
// when EOF and empty data // when EOF and empty data
@ -125,9 +208,9 @@ func EventScanner(props *EventScannerProps, config ...globals.ProxyConfig) *Even
} }
// callback chunk // callback chunk
if err := props.Callback(chunk); err != nil { if err := callback(chunk); err != nil {
// break connection on callback error // break connection on callback error
err := resp.Body.Close() err := body.Close()
if err != nil { if err != nil {
globals.Debug(fmt.Sprintf("[sse] event source close error: %s", err.Error())) globals.Debug(fmt.Sprintf("[sse] event source close error: %s", err.Error()))
} }