feat: add HTTP channel with WebSocket support
This commit is contained in:
parent
b9a66248d8
commit
efeedfcaae
5 changed files with 613 additions and 0 deletions
275
pkg/channels/http.go
Normal file
275
pkg/channels/http.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
//go:embed http_templates/*
|
||||
var httpTemplates embed.FS
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type HTTPChannel struct {
|
||||
*BaseChannel
|
||||
server *http.Server
|
||||
config *config.HTTPConfig
|
||||
clients sync.Map // chatID -> *websocket.Conn
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*HTTPSession
|
||||
}
|
||||
|
||||
type HTTPSession struct {
|
||||
ChatID string
|
||||
Conn *websocket.Conn
|
||||
Connected time.Time
|
||||
}
|
||||
|
||||
type WSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
func NewHTTPChannel(cfg *config.HTTPConfig, messageBus *bus.MessageBus) (*HTTPChannel, error) {
|
||||
base := NewBaseChannel("http", cfg, messageBus, cfg.AllowFrom)
|
||||
|
||||
return &HTTPChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
sessions: make(map[string]*HTTPSession),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *HTTPChannel) Start(ctx context.Context) error {
|
||||
host := c.config.Host
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
port := c.config.Port
|
||||
if port == 0 {
|
||||
port = 8080
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", c.handleIndex)
|
||||
mux.HandleFunc("/ws", c.handleWebSocket)
|
||||
mux.HandleFunc("/api/chat", c.handleChatAPI)
|
||||
|
||||
c.server = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", host, port),
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.InfoCF("http", "HTTP server starting", map[string]any{
|
||||
"address": fmt.Sprintf("http://%s:%d", host, port),
|
||||
})
|
||||
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("http", "HTTP server error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoCF("http", "HTTP channel started", map[string]any{
|
||||
"host": host,
|
||||
"port": port,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HTTPChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("http", "Stopping HTTP server...")
|
||||
c.setRunning(false)
|
||||
|
||||
if c.server != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.server.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("failed to shutdown HTTP server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HTTPChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("http channel not running")
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
session, exists := c.sessions[msg.ChatID]
|
||||
c.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
logger.DebugCF("http", "No active session for chat", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
wsMsg := WSMessage{
|
||||
Type: "response",
|
||||
Content: msg.Content,
|
||||
ChatID: msg.ChatID,
|
||||
}
|
||||
|
||||
if err := session.Conn.WriteJSON(wsMsg); err != nil {
|
||||
logger.ErrorCF("http", "Failed to send WebSocket message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.removeSession(msg.ChatID)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HTTPChannel) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, err := template.ParseFS(httpTemplates, "http_templates/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load template", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Title string
|
||||
}{
|
||||
Title: "PicoClaw Web Chat",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
logger.ErrorCF("http", "Failed to execute template", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTPChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
logger.ErrorCF("http", "WebSocket upgrade failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
chatID := generateChatID(r)
|
||||
|
||||
c.mu.Lock()
|
||||
c.sessions[chatID] = &HTTPSession{
|
||||
ChatID: chatID,
|
||||
Conn: conn,
|
||||
Connected: time.Now(),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
logger.InfoCF("http", "WebSocket client connected", map[string]any{
|
||||
"chat_id": chatID,
|
||||
})
|
||||
|
||||
defer func() {
|
||||
c.removeSession(chatID)
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg WSMessage
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
logger.ErrorCF("http", "WebSocket read error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "message":
|
||||
c.handleIncomingMessage(chatID, msg.Content)
|
||||
case "ping":
|
||||
_ = conn.WriteJSON(WSMessage{Type: "pong"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTPChannel) handleChatAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ChatID == "" {
|
||||
req.ChatID = generateChatID(r)
|
||||
}
|
||||
|
||||
c.handleIncomingMessage(req.ChatID, req.Content)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"chat_id": req.ChatID,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *HTTPChannel) handleIncomingMessage(chatID, content string) {
|
||||
senderID := "web_user"
|
||||
|
||||
logger.DebugCF("http", "Received message", map[string]any{
|
||||
"chat_id": chatID,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, nil, map[string]string{
|
||||
"peer_kind": "direct",
|
||||
"peer_id": chatID,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *HTTPChannel) removeSession(chatID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if session, exists := c.sessions[chatID]; exists {
|
||||
session.Conn.Close()
|
||||
delete(c.sessions, chatID)
|
||||
logger.InfoCF("http", "WebSocket client disconnected", map[string]any{
|
||||
"chat_id": chatID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func generateChatID(r *http.Request) string {
|
||||
return fmt.Sprintf("web_%d", time.Now().UnixNano())
|
||||
}
|
||||
311
pkg/channels/http_templates/index.html
Normal file
311
pkg/channels/http_templates/index.html
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
background: #0f0f23;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 90vh;
|
||||
min-height: 600px;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(90deg, #ff6b35 0%, #f7931e 100%);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.header h1 {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header .logo {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.status {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
.status-dot.connected {
|
||||
background: #22c55e;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
.message {
|
||||
max-width: 80%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #f7931e 100%);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.message.assistant pre {
|
||||
background: #0f172a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.message.assistant code {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.input-container {
|
||||
padding: 20px;
|
||||
background: #1a1a2e;
|
||||
border-top: 1px solid #2d2d44;
|
||||
}
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.input-wrapper input {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
border: 2px solid #2d2d44;
|
||||
border-radius: 12px;
|
||||
background: #0f0f23;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input-wrapper input:focus {
|
||||
border-color: #ff6b35;
|
||||
}
|
||||
.input-wrapper input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
.input-wrapper button {
|
||||
padding: 14px 28px;
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #f7931e 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.input-wrapper button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(255, 107, 53, 0.4);
|
||||
}
|
||||
.input-wrapper button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.input-wrapper button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
background: #1e293b;
|
||||
border-radius: 16px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.typing span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #64748b;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite;
|
||||
}
|
||||
.typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typing {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<span class="logo">🦞</span>
|
||||
<h1>PicoClaw</h1>
|
||||
<div class="status">
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-container" id="chatContainer">
|
||||
<div class="message assistant">
|
||||
👋 ¡Hola! Soy PicoClaw, tu asistente AI. ¿En qué puedo ayudarte?
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-container">
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="messageInput" placeholder="Escribe tu mensaje..." autocomplete="off">
|
||||
<button id="sendButton">Enviar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const chatContainer = document.getElementById('chatContainer');
|
||||
const messageInput = document.getElementById('messageInput');
|
||||
const sendButton = document.getElementById('sendButton');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
let ws = null;
|
||||
let connected = false;
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||
|
||||
ws.onopen = () => {
|
||||
connected = true;
|
||||
statusDot.classList.add('connected');
|
||||
statusText.textContent = 'Conectado';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected = false;
|
||||
statusDot.classList.remove('connected');
|
||||
statusText.textContent = 'Desconectado';
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'response') {
|
||||
removeTypingIndicator();
|
||||
addMessage(data.content, 'assistant');
|
||||
} else if (data.type === 'pong') {
|
||||
// Heartbeat response
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function addMessage(content, type) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${type}`;
|
||||
|
||||
// Simple markdown-like formatting for code blocks
|
||||
content = content.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
|
||||
content = content.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
content = content.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
content = content.replace(/\n/g, '<br>');
|
||||
|
||||
div.innerHTML = content;
|
||||
chatContainer.appendChild(div);
|
||||
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
}
|
||||
|
||||
function showTypingIndicator() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'typing';
|
||||
div.id = 'typingIndicator';
|
||||
div.innerHTML = '<span></span><span></span><span></span>';
|
||||
chatContainer.appendChild(div);
|
||||
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
}
|
||||
|
||||
function removeTypingIndicator() {
|
||||
const indicator = document.getElementById('typingIndicator');
|
||||
if (indicator) indicator.remove();
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const content = messageInput.value.trim();
|
||||
if (!content || !connected) return;
|
||||
|
||||
addMessage(content, 'user');
|
||||
messageInput.value = '';
|
||||
sendButton.disabled = true;
|
||||
|
||||
showTypingIndicator();
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
content: content
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
sendButton.disabled = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
sendButton.addEventListener('click', sendMessage);
|
||||
messageInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') sendMessage();
|
||||
});
|
||||
|
||||
// Heartbeat to keep connection alive
|
||||
setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -202,6 +202,19 @@ func (m *Manager) initChannels() error {
|
|||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.HTTP.Enabled {
|
||||
logger.DebugC("channels", "Attempting to initialize HTTP channel")
|
||||
http, err := NewHTTPChannel(&m.config.Channels.HTTP, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize HTTP channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["http"] = http
|
||||
logger.InfoC("channels", "HTTP channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||
"enabled_channels": len(m.channels),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ type ChannelsConfig struct {
|
|||
OneBot OneBotConfig `json:"onebot"`
|
||||
WeCom WeComConfig `json:"wecom"`
|
||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
}
|
||||
|
||||
type WhatsAppConfig struct {
|
||||
|
|
@ -296,6 +297,13 @@ type WeComAppConfig struct {
|
|||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_HTTP_ENABLED"`
|
||||
Host string `json:"host" env:"PICOCLAW_CHANNELS_HTTP_HOST"`
|
||||
Port int `json:"port" env:"PICOCLAW_CHANNELS_HTTP_PORT"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_HTTP_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||
|
|
|
|||
|
|
@ -113,6 +113,12 @@ func DefaultConfig() *Config {
|
|||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
},
|
||||
HTTP: HTTPConfig{
|
||||
Enabled: false,
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue