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:
eaiadmin
2026-09-24 21:13:19 +08:00
parent 593323a934
commit 89ae31c998
60 changed files with 7251 additions and 967 deletions
+57 -22
View File
@@ -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()
}
// ──────────────────────────────────────────────