feat: 微信公众号技能包重命名(weixin_public_account)并增强功能
- 将 wechat_official_account 重命名为 weixin_public_account,符合中文命名规范 - 新增 DOCX 文档生成技能、聊天历史、请求 ID 中间件 - 增强工作流、热点服务、文章服务等模块功能 - 前端同步重命名组件和 API - 新增架构文档 AR13/AR14、专员文档更新 - 补充测试用例(seed_specialists_test, db_migration_test) Co-Authored-AI: yes
This commit is contained in:
@@ -50,15 +50,22 @@ type Client struct {
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// defaultHTTPTimeout 默认 HTTP 整体超时;路由可用 timeout_seconds 单独覆盖
|
||||
const defaultHTTPTimeout = 60 * time.Second
|
||||
|
||||
// NewClient 从 RouteConfig 创建客户端
|
||||
func NewClient(aiRoute *config.RouteConfig) *Client {
|
||||
timeout := defaultHTTPTimeout
|
||||
if aiRoute.TimeoutSeconds > 0 {
|
||||
timeout = time.Duration(aiRoute.TimeoutSeconds) * time.Second
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(aiRoute.BaseURL, "/"),
|
||||
apiKey: aiRoute.APIKey,
|
||||
model: aiRoute.Model,
|
||||
maxTokens: aiRoute.MaxTokens,
|
||||
temperature: aiRoute.Temperature,
|
||||
hc: &http.Client{Timeout: 120 * time.Second},
|
||||
hc: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,32 +222,60 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error
|
||||
return fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
|
||||
}
|
||||
|
||||
// 流式空闲超时保护:若超过 idleTimeout 没有收到任何可消费 chunk,
|
||||
// 判定为卡死并返回错误,避免前端无限等待(此前本地模型卡顿时会干等 120s)。
|
||||
const idleTimeout = 45 * time.Second
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
scannerCh := make(chan string, 1)
|
||||
// 驱动 scanner 的 goroutine,配合 select 实现空闲超时。
|
||||
// 函数返回时 defer resp.Body.Close() 会让 scanner.Scan() 立即返回,
|
||||
// goroutine 随之退出并通过 close 通知主循环(缓冲 channel 避免阻塞发送)。
|
||||
go func() {
|
||||
for scanner.Scan() {
|
||||
scannerCh <- scanner.Text()
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "[DONE]" {
|
||||
break
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
onChunk(chunk.Choices[0].Delta.Content)
|
||||
close(scannerCh)
|
||||
}()
|
||||
|
||||
idleTimer := time.NewTimer(idleTimeout)
|
||||
defer idleTimer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-idleTimer.C:
|
||||
return fmt.Errorf("LLM 流式响应空闲超时(%s 内无数据)", idleTimeout)
|
||||
case line, ok := <-scannerCh:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "[DONE]" {
|
||||
return nil
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
// 收到有效内容,重置空闲计时
|
||||
if !idleTimer.Stop() {
|
||||
<-idleTimer.C
|
||||
}
|
||||
idleTimer.Reset(idleTimeout)
|
||||
onChunk(chunk.Choices[0].Delta.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user