Compare commits

3 Commits

Author SHA1 Message Date
1ca66dc0af docs(batch): 更新批处理脚本注释
更新启动开发和打包批处理脚本中的注释内容,
使描述更加简洁明了,并统一为英文注释。
2026-06-26 14:40:44 +08:00
849090a627 feat(auto-reply): 优化自动回复逻辑和知识库功能
- 将默认回复详细程度从"detailed"调整为"medium",前后端保持一致
- 新增话题切换检测逻辑,当用户主动要求换话题时提供引导回复
- 优化上下文处理机制,仅在指代型追问时注入历史对话,避免模型复读旧内容
- 改进知识库检索逻辑,区分自包含问题和指代型问题的上下文需求
- 完善知识库完整性指令,确保回复详细程度与知识展开程度一致
- 重构知识库重建逻辑,支持递归扫描子目录中的文件,修复索引为空的问题
- 增强素材匹配算法,引入强信号检测机制,避免仅凭模糊匹配误发素材
- 新增素材开场白AI生成功能,支持图片、视频、文档等类型智能描述
- 改进知识库重建通知,显示具体的文件数、分片数及失败统计信息
2026-06-26 14:25:35 +08:00
1517be2a25 feat(auto-reply): 接入万川平台模型配置 + 各模型独立网关回退
万川平台对接
- 新增 wanchuan_proxy.go:WanchuanLogin/WanchuanGetModel 代理登录与按 code 拉取模型,
  日志对 password/token/apiKey 打码(含 encryptedConfig 二次解析)
- 新增 PlatformConfig(baseUrl/username/password)及 Get/SavePlatformConfig 持久化
- 前端万川卡片:登录→拉取 chat/vision/embedding/rerank/voice→回填 form 并保存→必要时重建向量索引

各模型独立网关(url+key),留空回退聊天网关
- RetrievalConfig 新增 embeddingBaseUrl/embeddingApiKey、rerankBaseUrl/rerankApiKey
- embeddingRequestConfig/rerankRequestConfig:优先独立网关,未配置回退 AI.BaseURL/APIKey
- vision/audio 同模式:非 DashScope 网关下视觉/语音模型留空时不再锁死或强写 DashScope,
  运行期由 fallbackString(VisionModel, Model) 动态复用聊天模型

陈旧向量空间防护
- loadEmbeddingIndex 检测磁盘索引与当前 embedding 模型/维度不一致时清空向量、回退关键词检索,
  并提示重建(embeddingIndexStaleReason,兼容旧版无模型名索引)

UI 状态修复
- 登录拉模型期间统一置全局 busy,禁用闸门收敛为 busy(与刷新联系人等按钮同范式),
  platformBusy 仅保留用于按钮「处理中…」文案,杜绝并发读写 form 与反向可点洞

其他
- 删除遗留 helper/auto_reply_ai.go.bak
- 补充 config/helper 单元测试(视觉回退分支、陈旧索引判定)
2026-06-26 10:17:02 +08:00
21 changed files with 1747 additions and 1027 deletions

View File

@@ -162,6 +162,15 @@ func UpdateAutoReplyConfig(autoReplyConfig AutoReplyConfig) error {
return nil
}
// UpdatePlatformConfig updates Wanchuan platform configuration.
func UpdatePlatformConfig(platformConfig PlatformConfig) error {
if globalConfig != nil {
globalConfig.PlatformConfig = platformConfig
return SaveGlobalConfig()
}
return nil
}
// ReloadGlobalConfig reloads config.json from disk.
func ReloadGlobalConfig() (*Config, error) {
if globalConfigManager == nil {

View File

@@ -56,8 +56,12 @@ type RetrievalConfig struct {
RetrievalMode string `json:"retrievalMode"`
EmbeddingIndexPath string `json:"embeddingIndexPath"`
EmbeddingModel string `json:"embeddingModel"`
EmbeddingBaseURL string `json:"embeddingBaseUrl"`
EmbeddingAPIKey string `json:"embeddingApiKey"`
EmbeddingDimensions int `json:"embeddingDimensions"`
RerankModel string `json:"rerankModel"`
RerankBaseURL string `json:"rerankBaseUrl"`
RerankAPIKey string `json:"rerankApiKey"`
RecallTopK int `json:"recallTopK"`
RerankTopK int `json:"rerankTopK"`
FinalTopK int `json:"finalTopK"`
@@ -145,10 +149,18 @@ type ReplyPolicyConfig struct {
SensitiveKeywords []string `json:"sensitiveKeywords"`
}
// PlatformConfig stores Wanchuan platform credentials.
type PlatformConfig struct {
BaseURL string `json:"baseUrl"`
Username string `json:"username"`
Password string `json:"password"`
}
// Config stores the application configuration.
type Config struct {
CallbackConfig CallbackConfig `json:"callbackConfig"`
AutoReplyConfig AutoReplyConfig `json:"autoReplyConfig"`
PlatformConfig PlatformConfig `json:"platformConfig"`
LastUpdated int64 `json:"lastUpdated"`
}
@@ -165,7 +177,12 @@ func NewDefaultConfig() *Config {
DeviceCode: "",
},
AutoReplyConfig: NewDefaultAutoReplyConfig(),
LastUpdated: time.Now().Unix(),
PlatformConfig: PlatformConfig{
BaseURL: "",
Username: "",
Password: "",
},
LastUpdated: time.Now().Unix(),
}
}
@@ -236,7 +253,7 @@ func NewDefaultAutoReplyConfig() AutoReplyConfig {
AudioAPIKey: "",
TimeoutSeconds: 20,
EnableThinking: false,
ReplyDetail: "detailed",
ReplyDetail: "medium",
Temperature: 0,
MaxTokens: 700,
},
@@ -399,11 +416,26 @@ func (c *Config) ApplyDefaults() {
if strings.TrimSpace(c.AutoReplyConfig.AI.SystemPrompt) == "" {
c.AutoReplyConfig.AI.SystemPrompt = defaultAuto.AI.SystemPrompt
}
if c.AutoReplyConfig.AI.VisionModel == "" ||
(strings.EqualFold(c.AutoReplyConfig.AI.VisionModel, c.AutoReplyConfig.AI.Model) &&
!isVisionCapableModelName(c.AutoReplyConfig.AI.VisionModel)) ||
isLikelyTextOnlyQwenModel(c.AutoReplyConfig.AI.VisionModel) {
c.AutoReplyConfig.AI.VisionModel = defaultAuto.AI.VisionModel
visionGateway := strings.TrimSpace(c.AutoReplyConfig.AI.VisionBaseURL)
if visionGateway == "" {
visionGateway = strings.TrimSpace(c.AutoReplyConfig.AI.BaseURL)
}
if isDashScopeGateway(visionGateway) {
// DashScope 网关:空/文本模型一律回退到专用视觉模型 qwen3-vl-plus
if c.AutoReplyConfig.AI.VisionModel == "" ||
(strings.EqualFold(c.AutoReplyConfig.AI.VisionModel, c.AutoReplyConfig.AI.Model) &&
!isVisionCapableModelName(c.AutoReplyConfig.AI.VisionModel)) ||
isLikelyTextOnlyQwenModel(c.AutoReplyConfig.AI.VisionModel) {
c.AutoReplyConfig.AI.VisionModel = defaultAuto.AI.VisionModel
}
} else if strings.TrimSpace(c.AutoReplyConfig.AI.VisionBaseURL) == "" {
// 非 DashScope 且没有独立视觉网关(如万川统一网关):
// 视觉留空时清空 VisionModel让请求期 fallbackString(VisionModel, Model) 动态复用聊天模型,
// 这样后续用户改聊天模型,视觉会自动跟随,不会被锁死在旧值。
// 仅当用户在同一网关上显式填了与聊天模型不同的视觉模型时才保留其选择。
if strings.EqualFold(strings.TrimSpace(c.AutoReplyConfig.AI.VisionModel), strings.TrimSpace(c.AutoReplyConfig.AI.Model)) {
c.AutoReplyConfig.AI.VisionModel = ""
}
}
if c.AutoReplyConfig.AI.AudioProvider == "" {
c.AutoReplyConfig.AI.AudioProvider = defaultAuto.AI.AudioProvider
@@ -509,6 +541,11 @@ func dedupeStrings(items []string) []string {
return result
}
// isDashScopeGateway 判断网关是否为阿里云 DashScopeqwen3-vl-plus 等默认模型仅对其有意义)
func isDashScopeGateway(url string) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(url)), "dashscope.aliyuncs.com")
}
func isVisionCapableModelName(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
return strings.Contains(name, "vl") ||

View File

@@ -99,3 +99,49 @@ func TestApplyDefaultsFixesWrongRerankConfig(t *testing.T) {
t.Errorf("Expected rerank model to be corrected to 'qwen3-rerank', got %q", cfg.AutoReplyConfig.Retrieval.RerankModel)
}
}
// 非 DashScope 统一网关(如万川)且无独立视觉网关时:视觉模型 == 聊天模型应被清空,
// 以便运行期 fallbackString(VisionModel, Model) 动态跟随聊天模型,不锁死旧值。
func TestApplyDefaultsNonDashScopeVisionFollowsChat(t *testing.T) {
cfg := NewDefaultConfig()
cfg.AutoReplyConfig.AI.BaseURL = "https://wanchuan.example/v1"
cfg.AutoReplyConfig.AI.Model = "wanchuan-chat"
cfg.AutoReplyConfig.AI.VisionBaseURL = ""
cfg.AutoReplyConfig.AI.VisionModel = "wanchuan-chat" // 与聊天模型相同(之前回填留下的值)
cfg.ApplyDefaults()
if cfg.AutoReplyConfig.AI.VisionModel != "" {
t.Errorf("Expected vision model cleared to follow chat on non-DashScope unified gateway, got %q", cfg.AutoReplyConfig.AI.VisionModel)
}
}
// 非 DashScope 网关上用户在同一网关显式填了不同的视觉模型时,应保留其选择。
func TestApplyDefaultsNonDashScopeKeepsExplicitVision(t *testing.T) {
cfg := NewDefaultConfig()
cfg.AutoReplyConfig.AI.BaseURL = "https://wanchuan.example/v1"
cfg.AutoReplyConfig.AI.Model = "wanchuan-chat"
cfg.AutoReplyConfig.AI.VisionBaseURL = ""
cfg.AutoReplyConfig.AI.VisionModel = "wanchuan-vl" // 与聊天模型不同,属用户显式选择
cfg.ApplyDefaults()
if cfg.AutoReplyConfig.AI.VisionModel != "wanchuan-vl" {
t.Errorf("Expected explicit different vision model preserved, got %q", cfg.AutoReplyConfig.AI.VisionModel)
}
}
// DashScope 网关:视觉模型为空或文本模型时仍应回退到专用视觉模型,不受上面改动影响。
func TestApplyDefaultsDashScopeStillFallsBackToVisionModel(t *testing.T) {
cfg := NewDefaultConfig()
cfg.AutoReplyConfig.AI.BaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
cfg.AutoReplyConfig.AI.Model = "qwen-turbo"
cfg.AutoReplyConfig.AI.VisionBaseURL = ""
cfg.AutoReplyConfig.AI.VisionModel = ""
cfg.ApplyDefaults()
if cfg.AutoReplyConfig.AI.VisionModel != defaultVisionModel {
t.Errorf("Expected DashScope vision fallback to %q, got %q", defaultVisionModel, cfg.AutoReplyConfig.AI.VisionModel)
}
}

View File

@@ -54,7 +54,8 @@
</div>
<div class="metric">
<span>身份缓存</span>
<strong :class="status.identityRefreshing ? 'muted' : ''">{{ status.identityRefreshing ? '刷新中' : formatUnixTime(status.identityLastRefreshAt) }}</strong>
<strong :class="status.identityRefreshing ? 'muted' : ''">{{ status.identityRefreshing ? '刷新中' :
formatUnixTime(status.identityLastRefreshAt) }}</strong>
</div>
<div class="metric">
<span>协同等待中</span>
@@ -79,13 +80,8 @@
</div>
<nav class="section-nav" aria-label="自动客服模块导航">
<button
v-for="item in sectionNavItems"
:key="item.id"
type="button"
class="section-nav-btn"
@click="scrollToSection(item.id)"
>
<button v-for="item in sectionNavItems" :key="item.id" type="button" class="section-nav-btn"
@click="scrollToSection(item.id)">
{{ item.label }}
</button>
</nav>
@@ -167,13 +163,49 @@
<div class="section-meta">
<span>{{ form.ai.provider === 'local' ? '本地模型' : '兼容接口' }}</span>
<span>文本{{ form.ai.model || '未配置' }}</span>
<span>图片{{ form.ai.visionModel || defaultVisionModel }}</span>
<span>图片{{ form.ai.visionModel || form.ai.model || defaultVisionModel }}{{ form.ai.visionModel ? '' :
'(复用文本模型)' }}</span>
<span>语音{{ form.ai.audioModel || '未配置' }}</span>
</div>
</div>
<div v-if="sectionAlerts('ai').length" class="section-alerts">
<div v-for="alert in sectionAlerts('ai')" :key="alert" class="section-alert">{{ alert }}</div>
</div>
<!-- 万川平台配置卡片 -->
<div class="config-subsection wanchuan-platform-card">
<div class="config-subsection-header">
<strong>万川平台</strong>
<span>从万川 AI 平台自动获取模型配置</span>
</div>
<div class="form-grid wanchuan-form-row">
<label>
<span>平台地址</span>
<input v-model="platformForm.baseUrl" placeholder="https://platform.example.com">
</label>
<label>
<span>账号</span>
<input v-model="platformForm.username" placeholder="用户名">
</label>
<label>
<span>密码</span>
<input v-model="platformForm.password" type="password" placeholder="密码">
</label>
<div class="wanchuan-actions">
<!-- 禁用统一走全局 busy刷新联系人等按钮同范式busy || 本按钮字段守卫
登录时 loginAndFetchModels 已置 busy=trueplatformBusy 仅保留用于本按钮处理中文案
避免别处操作占用 busy 时这里误显处理中 -->
<button class="ghost-btn" @click="loginAndFetchModels"
:disabled="busy || !platformForm.baseUrl || !platformForm.username || !platformForm.password">
{{ platformBusy ? '处理中...' : '登录并获取模型' }}
</button>
<button class="ghost-btn" @click="resetPlatformConfig"
:disabled="busy || !platformForm.baseUrl || !platformForm.username || !platformForm.password">重新获取模型</button>
</div>
</div>
<div v-if="platformMessage" class="inline-message" :class="platformMessageType">{{ platformMessage }}</div>
</div>
<div class="ai-config-groups">
<div class="config-subsection">
<div class="config-subsection-header">
@@ -342,6 +374,17 @@
用于文本向量化例如text-embedding-v4, text-embedding-v3
</small>
</label>
<label>
<span>Embedding Base URL</span>
<input v-model="form.retrieval.embeddingBaseUrl" placeholder="留空则复用文本模型网关">
<small style="color: #666; font-size: 12px; margin-top: 4px; display: block;">
向量模型独立网关留空回退到文本回复 Base URL
</small>
</label>
<label>
<span>Embedding API Key</span>
<input v-model="form.retrieval.embeddingApiKey" type="password" placeholder="留空则复用文本模型密钥">
</label>
<label>
<span>Embedding 维度</span>
<input type="number" v-model.number="form.retrieval.embeddingDimensions" min="128">
@@ -353,6 +396,17 @@
用于结果重排序例如qwen3-rerank, gte-rerank-v2
</small>
</label>
<label>
<span>Rerank Base URL</span>
<input v-model="form.retrieval.rerankBaseUrl" placeholder="留空则复用文本模型网关">
<small style="color: #666; font-size: 12px; margin-top: 4px; display: block;">
重排模型独立网关留空回退到文本回复 Base URL
</small>
</label>
<label>
<span>Rerank API Key</span>
<input v-model="form.retrieval.rerankApiKey" type="password" placeholder="留空则复用文本模型密钥">
</label>
<label>
<span>召回 TopK</span>
<input type="number" v-model.number="form.retrieval.recallTopK" min="1">
@@ -425,24 +479,13 @@
<label>
<span>人工同事</span>
<div class="handoff-human-combobox">
<input
v-model="handoffHumanQuery"
class="handoff-human-search"
placeholder="输入姓名、拼音、userID 模糊搜索"
@focus="startHandoffHumanEditing"
@input="handleHandoffHumanInput"
@keydown.enter.prevent="previewFirstHandoffHuman"
@keydown.esc.prevent="closeHandoffHumanDropdown"
>
<input v-model="handoffHumanQuery" class="handoff-human-search" placeholder="输入姓名、拼音、userID 模糊搜索"
@focus="startHandoffHumanEditing" @input="handleHandoffHumanInput"
@keydown.enter.prevent="previewFirstHandoffHuman" @keydown.esc.prevent="closeHandoffHumanDropdown">
<div v-if="handoffHumanDropdownOpen" class="handoff-human-dropdown">
<button
v-for="item in handoffHumanOptions"
:key="item.userId"
type="button"
class="handoff-human-option"
<button v-for="item in handoffHumanOptions" :key="item.userId" type="button" class="handoff-human-option"
:class="{ selected: pendingHandoffHumanId === item.userId }"
@mousedown.prevent="previewHandoffHuman(item)"
>
@mousedown.prevent="previewHandoffHuman(item)">
<strong>{{ identityOptionName(item) }}</strong>
<span>{{ item.userId }}</span>
<small>{{ identitySourceLabel(item.source) }}</small>
@@ -453,7 +496,8 @@
</div>
</div>
<div class="select-action-row">
<button type="button" class="mini-btn" @click="confirmHandoffHuman" :disabled="!canConfirmHandoffHuman">确认选择</button>
<button type="button" class="mini-btn" @click="confirmHandoffHuman"
:disabled="!canConfirmHandoffHuman">确认选择</button>
<button type="button" class="mini-btn" @click="clearHandoffHuman">清空</button>
</div>
<small class="field-hint">{{ selectedHandoffHumanSummary }}</small>
@@ -480,7 +524,8 @@
</label>
<label class="wide">
<span>通知模板</span>
<textarea v-model="form.handoff.messageTemplate" rows="8" :placeholder="defaultHandoffTemplateHint"></textarea>
<textarea v-model="form.handoff.messageTemplate" rows="8"
:placeholder="defaultHandoffTemplateHint"></textarea>
<small class="field-hint">留空时会按私聊/群聊自动使用上面的默认模板填写后则使用你自定义的模板</small>
</label>
</div>
@@ -499,7 +544,8 @@
<div class="section-meta">
<span>内部 {{ status.internalContactCount || 0 }}</span>
<span>外部 {{ status.externalContactCount || 0 }}</span>
<span>缓存 {{ status.identityInitializing ? '初始化中' : (status.identityRefreshing ? '刷新中' : formatUnixTime(status.identityLastRefreshAt)) }}</span>
<span>缓存 {{ status.identityInitializing ? '初始化中' : (status.identityRefreshing ? '刷新中' :
formatUnixTime(status.identityLastRefreshAt)) }}</span>
</div>
</div>
<div v-if="sectionAlerts('identity').length" class="section-alerts">
@@ -550,20 +596,24 @@
<input v-model="internalGroupQuery" placeholder="搜索总群名称或 conversationId">
<select v-model="selectedInternalGroupId" :disabled="internalGroupFilteredOptions.length === 0">
<option value="">{{ groupSelectHint() }}</option>
<option v-for="item in internalGroupFilteredOptions" :key="item.conversationId" :value="item.conversationId">
<option v-for="item in internalGroupFilteredOptions" :key="item.conversationId"
:value="item.conversationId">
{{ groupOptionLabel(item) }}
</option>
</select>
<button type="button" class="ghost-btn" @click="addSelectedInternalGroup" :disabled="!canAddInternalGroup">添加</button>
<button type="button" class="ghost-btn" @click="addSelectedInternalGroup"
:disabled="!canAddInternalGroup">添加</button>
</div>
<div v-if="groupOptions.length === 0" class="field-hint">先点击刷新群列表再选择企业总群未选择总群时只同步企微联系人列表</div>
<div v-if="internalSelectedGroups.length" class="identity-selected-list">
<div v-for="item in internalSelectedGroups" :key="item.conversationId" class="identity-selected-row group-selected-row">
<div v-for="item in internalSelectedGroups" :key="item.conversationId"
class="identity-selected-row group-selected-row">
<strong>{{ item.name || '未命名群聊' }}</strong>
<span class="identity-user-id">{{ item.conversationId }}</span>
<span>{{ item.source || '群列表' }}</span>
<span></span>
<button type="button" class="ghost-btn small-btn" @click="removeInternalGroup(item.conversationId)">删除</button>
<button type="button" class="ghost-btn small-btn"
@click="removeInternalGroup(item.conversationId)">删除</button>
</div>
</div>
<div v-else class="identity-empty">暂无内部成员同步群</div>
@@ -624,23 +674,21 @@
{{ identityOptionLabel(item) }}
</option>
</select>
<button type="button" class="ghost-btn" @click="addSelectedIdentity('internal')" :disabled="!canAddIdentity('internal')">添加</button>
<button type="button" class="ghost-btn" @click="addSelectedIdentity('internal')"
:disabled="!canAddIdentity('internal')">添加</button>
</div>
<div v-if="internalSelectedContacts.length" class="identity-selected-list">
<div v-for="item in internalSelectedContacts" :key="item.userId" class="identity-selected-row">
<strong>{{ item.name || '未填写名称' }}</strong>
<span class="identity-user-id">{{ item.userId }}</span>
<span>{{ identitySourceLabel(item.source) }}</span>
<input
v-if="item.needsLabel"
:value="identityManualLabelDraft('internal', item.userId)"
placeholder="补充名称"
@input="setIdentityLabelDraft('internal', item.userId, $event.target.value)"
<input v-if="item.needsLabel" :value="identityManualLabelDraft('internal', item.userId)"
placeholder="补充名称" @input="setIdentityLabelDraft('internal', item.userId, $event.target.value)"
@blur="commitIdentityLabelDraft('internal', item.userId)"
@keydown.enter.prevent="commitIdentityLabelDraft('internal', item.userId)"
>
@keydown.enter.prevent="commitIdentityLabelDraft('internal', item.userId)">
<span v-else></span>
<button type="button" class="ghost-btn small-btn" @click="removeIdentity('internal', item.userId)">删除</button>
<button type="button" class="ghost-btn small-btn"
@click="removeIdentity('internal', item.userId)">删除</button>
</div>
</div>
<div v-else class="identity-empty">暂无手动内部员工</div>
@@ -658,30 +706,29 @@
{{ identityOptionLabel(item) }}
</option>
</select>
<button type="button" class="ghost-btn" @click="addSelectedIdentity('external')" :disabled="!canAddIdentity('external')">添加</button>
<button type="button" class="ghost-btn" @click="addSelectedIdentity('external')"
:disabled="!canAddIdentity('external')">添加</button>
</div>
<div v-if="externalSelectedContacts.length" class="identity-selected-list">
<div v-for="item in externalSelectedContacts" :key="item.userId" class="identity-selected-row">
<strong>{{ item.name || '未填写名称' }}</strong>
<span class="identity-user-id">{{ item.userId }}</span>
<span>{{ identitySourceLabel(item.source) }}</span>
<input
v-if="item.needsLabel"
:value="identityManualLabelDraft('external', item.userId)"
placeholder="补充名称"
@input="setIdentityLabelDraft('external', item.userId, $event.target.value)"
<input v-if="item.needsLabel" :value="identityManualLabelDraft('external', item.userId)"
placeholder="补充名称" @input="setIdentityLabelDraft('external', item.userId, $event.target.value)"
@blur="commitIdentityLabelDraft('external', item.userId)"
@keydown.enter.prevent="commitIdentityLabelDraft('external', item.userId)"
>
@keydown.enter.prevent="commitIdentityLabelDraft('external', item.userId)">
<span v-else></span>
<button type="button" class="ghost-btn small-btn" @click="removeIdentity('external', item.userId)">删除</button>
<button type="button" class="ghost-btn small-btn"
@click="removeIdentity('external', item.userId)">删除</button>
</div>
</div>
<div v-else class="identity-empty">暂无手动外部客户</div>
</div>
<label>
<span>批量粘贴内部员工 ID</span>
<textarea v-model="internalUserIdsText" rows="3" placeholder="每行一个 user_id例如 1688855899845302"></textarea>
<textarea v-model="internalUserIdsText" rows="3"
placeholder="每行一个 user_id例如 1688855899845302"></textarea>
</label>
<label>
<span>批量粘贴外部客户 ID</span>
@@ -705,7 +752,8 @@
<button class="ghost-btn" @click="saveConfig('identity')" :disabled="busy">保存配置</button>
<button class="ghost-btn" @click="refreshContacts" :disabled="busy || status.identityRefreshing">刷新联系人</button>
<button class="ghost-btn" @click="refreshGroups" :disabled="busy">刷新群列表</button>
<button class="ghost-btn" @click="syncInternalGroups" :disabled="busy || status.identityRefreshing || !form.identity.internalGroupConversationIds.length">同步内部成员群</button>
<button class="ghost-btn" @click="syncInternalGroups"
:disabled="busy || status.identityRefreshing || !form.identity.internalGroupConversationIds.length">同步内部成员群</button>
</div>
</section>
@@ -756,11 +804,15 @@
<div v-if="!status.lastMessages || status.lastMessages.length === 0" class="empty">暂无处理记录</div>
</div>
<div v-if="recordTotalCount > 0" class="record-pagination">
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(1)" :disabled="recordCurrentPage <= 1">首页</button>
<button class="ghost-btn small-btn" type="button" @click="prevRecordPage" :disabled="recordCurrentPage <= 1">上一</button>
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(1)"
:disabled="recordCurrentPage <= 1"></button>
<button class="ghost-btn small-btn" type="button" @click="prevRecordPage"
:disabled="recordCurrentPage <= 1">上一页</button>
<span>{{ recordPageSummary }}显示 {{ recordStartIndex }}-{{ recordEndIndex }} / {{ recordTotalCount }}</span>
<button class="ghost-btn small-btn" type="button" @click="nextRecordPage" :disabled="recordCurrentPage >= recordTotalPages">下一页</button>
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(recordTotalPages)" :disabled="recordCurrentPage >= recordTotalPages"></button>
<button class="ghost-btn small-btn" type="button" @click="nextRecordPage"
:disabled="recordCurrentPage >= recordTotalPages">下一</button>
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(recordTotalPages)"
:disabled="recordCurrentPage >= recordTotalPages">末页</button>
</div>
</section>
</div>
@@ -783,7 +835,11 @@ import {
TestAIConnection,
TestHumanHandoff,
SendWxWorkData,
LogFrontend
LogFrontend,
GetPlatformConfig,
SavePlatformConfig,
WanchuanLogin,
WanchuanGetModel
} from '../../wailsjs/go/main/App.js'
const busy = ref(false)
@@ -815,6 +871,16 @@ let identityOptionsKey = ''
let currentIdentityScopeKey = ''
let timer = null
// 万川平台相关状态
const platformBusy = ref(false)
const platformMessage = ref('')
const platformMessageType = ref('success')
const platformForm = reactive({
baseUrl: '',
username: '',
password: ''
})
const form = reactive(defaultConfig())
const sectionNavItems = [
{ id: 'listen', label: '监听策略' },
@@ -1055,8 +1121,12 @@ function defaultConfig() {
retrievalMode: 'hybrid_rerank',
embeddingIndexPath: 'config/knowledge/embedding_index.json',
embeddingModel: 'text-embedding-v4',
embeddingBaseUrl: '',
embeddingApiKey: '',
embeddingDimensions: 512,
rerankModel: 'qwen3-rerank',
rerankBaseUrl: '',
rerankApiKey: '',
recallTopK: 50,
rerankTopK: 30,
finalTopK: 8
@@ -1092,7 +1162,7 @@ function defaultConfig() {
audioApiKey: '',
timeoutSeconds: 20,
enableThinking: false,
replyDetail: 'detailed',
replyDetail: 'medium',
temperature: 0,
maxTokens: 700
},
@@ -1397,15 +1467,15 @@ function filterHandoffHumanOptions(options, query) {
}
function handoffHumanMatchScore(item, query, selectedId) {
const userId = String(item.userId || '').trim()
const userId = String(item.userId || '').trim()
if (!userId || isRobotUserId(userId)) return -1
let score = userId === selectedId ? 20 : 0
if (!query) return score
const name = String(item.name || '').toLowerCase()
const source = String(item.source || '').toLowerCase()
const accountName = String(item.sourceAccountName || '').toLowerCase()
const accountUserId = String(item.sourceAccountUserId || '').toLowerCase()
const clientId = String(item.clientId || '').toLowerCase()
const name = String(item.name || '').toLowerCase()
const source = String(item.source || '').toLowerCase()
const accountName = String(item.sourceAccountName || '').toLowerCase()
const accountUserId = String(item.sourceAccountUserId || '').toLowerCase()
const clientId = String(item.clientId || '').toLowerCase()
const userIdLower = userId.toLowerCase()
const fields = [name, userIdLower, source, accountName, accountUserId, clientId].filter(Boolean)
if (name === query || userIdLower === query) return score + 100
@@ -1911,14 +1981,29 @@ function isLikelyTextOnlyQwenModel(model) {
['turbo', 'plus', 'max', 'long', 'coder', 'math', 'instruct'].some(token => name.includes(token))
}
function isDashScopeGateway(url) {
return String(url || '').toLowerCase().includes('dashscope.aliyuncs.com')
}
function normalizeVisionModelConfig() {
if (!form.ai) return
const visionModel = String(form.ai.visionModel || '').trim()
const textModel = String(form.ai.model || '').trim()
if (!visionModel ||
(visionModel.toLowerCase() === textModel.toLowerCase() && !isVisionCapableModelName(visionModel)) ||
isLikelyTextOnlyQwenModel(visionModel)) {
form.ai.visionModel = defaultVisionModel
const visionGateway = String(form.ai.visionBaseUrl || '').trim() || String(form.ai.baseUrl || '').trim()
if (isDashScopeGateway(visionGateway)) {
// DashScope 网关:空/文本模型一律回退到专用视觉模型 qwen3-vl-plus
if (!visionModel ||
(visionModel.toLowerCase() === textModel.toLowerCase() && !isVisionCapableModelName(visionModel)) ||
isLikelyTextOnlyQwenModel(visionModel)) {
form.ai.visionModel = defaultVisionModel
}
} else if (!String(form.ai.visionBaseUrl || '').trim()) {
// 非 DashScope 且无独立视觉网关(如万川统一网关):
// 视觉模型与聊天模型相同时清空,让后端 fallbackString(visionModel, model) 动态复用聊天模型,
// 这样改聊天模型时视觉会自动跟随,不会锁死在旧值。仅保留用户在同网关上显式填的不同视觉模型。
if (visionModel.toLowerCase() === textModel.toLowerCase()) {
form.ai.visionModel = ''
}
}
}
@@ -1933,8 +2018,10 @@ function normalizeAIConfigBeforeSave() {
if (!form.ai.audioProvider) form.ai.audioProvider = 'auto'
if (!form.ai.audioMode) form.ai.audioMode = 'openai_audio_chat'
if (!form.ai.audioModel) form.ai.audioModel = 'qwen3-asr-flash'
if (!form.ai.audioBaseUrl) form.ai.audioBaseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
if (!['concise', 'medium', 'detailed'].includes(form.ai.replyDetail)) form.ai.replyDetail = 'detailed'
// 语音网关与 vision/embedding/rerank 一致audioBaseUrl 留空时不强写 DashScope
// 运行期由后端 audioRequestConfig 回退复用聊天网关。否则非 DashScope如万川会出现
// 「DashScope URL + 万川聊天 Key」的错配导致鉴权失败。用户填了独立语音网关则保留其值。
if (!['concise', 'medium', 'detailed'].includes(form.ai.replyDetail)) form.ai.replyDetail = 'medium'
if (looksLikeUrl(form.ai.audioApiKey)) {
form.ai.audioApiKey = ''
notify('语音 API Key 误填为 URL已清空并复用主 API Key', 'warn', 'ai')
@@ -1969,6 +2056,267 @@ async function loadConfig() {
}
}
// 加载万川平台配置
async function loadPlatformConfig() {
try {
const cfg = await GetPlatformConfig()
if (cfg) {
platformForm.baseUrl = cfg.baseUrl || ''
platformForm.username = cfg.username || ''
platformForm.password = cfg.password || ''
}
} catch (err) {
LogFrontend('warn', `加载平台配置失败: ${err.message || err}`)
}
}
// 万川平台模型编码(平台按用途用不同 code 区分)
// 知识库三件套chat/embedding/rerank注册在 preSaleInSaleAfterSale- 业务前缀下;
// vision/voice 是平台系统级共享模型,用裸 code带前缀的 preSaleInSaleAfterSale-voice 平台会返回 500「功能模型不存在」
// 一切以平台 getByCode 实际返回为准。
const WANCHUAN_CODE_CHAT = 'preSaleInSaleAfterSale-chat' // 聊天/文本模型 → form.ai
const WANCHUAN_CODE_EMBEDDING = 'preSaleInSaleAfterSale-text-embedding' // 向量模型 → form.retrieval.embeddingModel
const WANCHUAN_CODE_RERANK = 'preSaleInSaleAfterSale-rerank' // 重排模型 → form.retrieval.rerankModel
const WANCHUAN_CODE_VOICE = 'voice' // 语音模型(ASR) → form.ai.audio*(裸 codeparaformer-v2
// 图片识别模型 → form.ai.vision*。平台把视觉模型注册在裸 code「vision」下functionName=售前售后视觉),
// 带独立 endpointUrl/apiKey与聊天网关不同必须按此 code 拉取并回填,不能复用聊天模型。
// 仍按「可选」处理:万一平台某天下线该 code自动回退运行时复用聊天模型不中断主流程。
const WANCHUAN_CODE_VISION = 'vision'
// 拉取一个「可选」模型配置:失败/平台无此 code 时返回 null不中断主流程。
// code 为空表示该用途万川暂未提供独立模型,直接跳过(运行时由后端回退复用聊天网关)。
async function fetchOptionalWanchuanModel(code, label, token) {
if (!code) {
LogFrontend('info', `[万川平台] 未配置 ${label} code跳过运行时回退复用聊天网关`)
return null
}
try {
LogFrontend('info', `[万川平台] 开始获取 ${label} 模型配置 (${code})`)
const response = await WanchuanGetModel(platformForm.baseUrl, code, token)
const model = parseModelConfig(JSON.parse(response), code)
if (!model || !model.modelName) return null
LogFrontend('info', `[万川平台] ${label} 解析结果 - modelName: ${model.modelName}, endpointUrl: ${model.endpointUrl}, apiKey 长度: ${model.apiKey?.length || 0}`)
return model
} catch (err) {
LogFrontend('warn', `[万川平台] ${label} 模型获取失败(跳过,运行时回退复用聊天网关): ${err.message || err}`)
return null
}
}
// 登录并获取模型配置
async function loginAndFetchModels() {
platformBusy.value = true
// 同时锁住全局 busy避免登录拉模型的数秒内用户点别处「保存配置」等按钮
// 与本函数内部的 saveConfig 并发读写同一个 form。saveConfig 的 ownsBusy 机制会识别
// busy 已被外层置位而不重复开关,逻辑自洽。
busy.value = true
platformMessage.value = ''
platformMessageType.value = 'success'
try {
// 记录回填前的 embedding 模型/维度,回填保存后若发生变化需重建向量索引(旧向量属于不同向量空间)
const prevEmbeddingModel = String(form.retrieval?.embeddingModel || '').trim()
const prevEmbeddingDims = Number(form.retrieval?.embeddingDimensions || 0)
// 1. 先保存凭证
// Wails 把 Go 的 (bool, string) 多返回值序列化的形态不固定:可能是数组、标量 true 或对象,需归一化判断
const saveResult = await SavePlatformConfig(JSON.stringify(platformForm))
const saveOk = Array.isArray(saveResult) ? saveResult[0] : Boolean(saveResult)
const saveMsg = Array.isArray(saveResult) ? saveResult[1] : ''
if (!saveOk) {
throw new Error(saveMsg || '保存平台配置失败')
}
// 2. 登录获取 token
LogFrontend('info', `[万川平台] 开始登录 - URL: ${platformForm.baseUrl}`)
const loginResponse = await WanchuanLogin(platformForm.baseUrl, platformForm.username, platformForm.password)
const loginData = JSON.parse(loginResponse)
LogFrontend('info', `[万川平台] 登录响应: ${JSON.stringify(loginData)}`)
// 多路径提取 token
const token = loginData.token ||
loginData.data?.token ||
loginData.data?.access_token ||
loginData.access_token
if (!token) {
throw new Error('登录失败:未获取到 token')
}
LogFrontend('info', `[万川平台] 登录成功token 长度: ${token.length}`)
// 3. 获取 chat 模型(必拉)→ 回填 form.aibaseUrl/apiKey/model 作为统一网关)
LogFrontend('info', `[万川平台] 开始获取 chat 模型配置 (${WANCHUAN_CODE_CHAT})`)
const chatResponse = await WanchuanGetModel(platformForm.baseUrl, WANCHUAN_CODE_CHAT, token)
const chatData = JSON.parse(chatResponse)
LogFrontend('info', `[万川平台] chat 响应: ${JSON.stringify(chatData)}`)
const chatModel = parseModelConfig(chatData, WANCHUAN_CODE_CHAT)
if (!chatModel || !chatModel.modelName) {
throw new Error('获取聊天模型配置失败')
}
LogFrontend('info', `[万川平台] chat 解析结果 - modelName: ${chatModel.modelName}, endpointUrl: ${chatModel.endpointUrl}, apiKey 长度: ${chatModel.apiKey?.length || 0}`)
// 回填聊天模型(网关地址 + 密钥 + 模型名)
if (chatModel.endpointUrl) form.ai.baseUrl = chatModel.endpointUrl
if (chatModel.apiKey) form.ai.apiKey = chatModel.apiKey
form.ai.model = chatModel.modelName
// 图片识别模型先置空,再按 vision code 拉取覆盖:
// 平台 vision 是独立模型(独立 endpointUrl/apiKey下面 helper 拉到就显式回填。
// 万一平台下线该 codehelper 返回 null保持留空后端 visionRequestConfig 用
// fallbackString(visionModel, model) 运行时复用聊天模型,不锁死旧值。
form.ai.visionBaseUrl = ''
form.ai.visionApiKey = ''
form.ai.visionModel = ''
// 3.5 获取 vision 模型(独立 code「vision」拉到则回填独立网关拉不到回退复用 chat→ 回填 form.ai.vision*
const visionModel = await fetchOptionalWanchuanModel(WANCHUAN_CODE_VISION, 'vision', token)
if (visionModel) {
form.ai.visionModel = visionModel.modelName
form.ai.visionBaseUrl = visionModel.endpointUrl || ''
form.ai.visionApiKey = visionModel.apiKey || ''
} else {
LogFrontend('info', `[万川平台] 未获取到独立视觉模型,图片识别运行时复用聊天模型: ${chatModel.modelName}`)
}
// 4. 获取 text-embedding 模型(可选)→ 回填 form.retrieval模型名 + 独立 url/key
const embeddingModel = await fetchOptionalWanchuanModel(WANCHUAN_CODE_EMBEDDING, 'embedding', token)
if (embeddingModel) {
form.retrieval.embeddingModel = embeddingModel.modelName
form.retrieval.embeddingBaseUrl = embeddingModel.endpointUrl || ''
form.retrieval.embeddingApiKey = embeddingModel.apiKey || ''
}
// 5. 获取 rerank 模型(可选)→ 回填 form.retrieval模型名 + 独立 url/key
const rerankModel = await fetchOptionalWanchuanModel(WANCHUAN_CODE_RERANK, 'rerank', token)
if (rerankModel) {
form.retrieval.rerankModel = rerankModel.modelName
form.retrieval.rerankBaseUrl = rerankModel.endpointUrl || ''
form.retrieval.rerankApiKey = rerankModel.apiKey || ''
}
// 6. 获取 voice 模型(可选)→ 回填 form.ai.audio*(独立 url/key/model
// 先清空语音网关覆盖项:拉到了显式回填;拉不到则留空,运行时由后端 audioRequestConfig 回退复用聊天网关,
// 避免残留的 DashScope 默认 URL 与万川聊天 Key 错配。(语音模型名仍由 ApplyDefaults 兜底/缺失提示引导手动配)
form.ai.audioBaseUrl = ''
form.ai.audioApiKey = ''
const voiceModel = await fetchOptionalWanchuanModel(WANCHUAN_CODE_VOICE, 'voice', token)
if (voiceModel) {
form.ai.audioModel = voiceModel.modelName
form.ai.audioBaseUrl = voiceModel.endpointUrl || ''
form.ai.audioApiKey = voiceModel.apiKey || ''
}
console.log('[万川平台] 模型配置获取完成:')
console.log(' chat:', chatModel)
console.log(' vision:', visionModel)
console.log(' embedding:', embeddingModel)
console.log(' rerank:', rerankModel)
console.log(' voice:', voiceModel)
// 7. 落盘并通知 helper 重载saveConfig 内部调用 SaveAutoReplyConfig → /api/auto-reply/reload
const saved = await saveConfig('ai', true)
if (!saved) {
throw new Error('模型已获取但保存失败,请检查 AI 配置后重试')
}
const visionLogText = visionModel ? visionModel.modelName : `复用chat(${chatModel.modelName})`
LogFrontend('info', `[万川平台] 模型配置已回填并保存 - chat: ${chatModel.modelName}, vision: ${visionLogText}, embedding: ${embeddingModel?.modelName || '无'}, rerank: ${rerankModel?.modelName || '无'}, voice: ${voiceModel?.modelName || '无'}`)
// 7.1 若 embedding 模型/维度发生变化,磁盘旧向量属于不同向量空间,需重建索引,
// 否则向量检索会静默回退关键词。无知识库内容时重建会快速跳过,不影响。
let rebuildNote = ''
if (embeddingModel && embeddingModel.modelName) {
const newEmbeddingModel = String(form.retrieval?.embeddingModel || '').trim()
const newEmbeddingDims = Number(form.retrieval?.embeddingDimensions || 0)
const embeddingChanged =
newEmbeddingModel.toLowerCase() !== prevEmbeddingModel.toLowerCase() ||
newEmbeddingDims !== prevEmbeddingDims
if (embeddingChanged) {
try {
LogFrontend('info', `[万川平台] embedding 模型变更(${prevEmbeddingModel || '空'}${newEmbeddingModel}),开始重建向量索引`)
const rebuildResult = await RebuildKnowledgeIndex()
if (rebuildResult?.success) {
rebuildNote = ',向量索引已重建'
LogFrontend('info', '[万川平台] 向量索引重建成功')
} else {
rebuildNote = `,但向量索引重建失败(请到知识库页手动重建):${rebuildResult?.message || '未知错误'}`
LogFrontend('warn', `[万川平台] 向量索引重建失败: ${rebuildResult?.message || '未知错误'}`)
}
await loadStatus()
} catch (err) {
rebuildNote = `,但向量索引重建异常(请到知识库页手动重建):${err.message || err}`
LogFrontend('warn', `[万川平台] 向量索引重建异常: ${err.message || err}`)
}
}
}
// 提示仍需手动配置的模型(平台未提供对应 code
const missing = []
if (!embeddingModel) missing.push('向量(embedding)')
if (!rerankModel) missing.push('重排(rerank)')
if (!voiceModel) missing.push('语音(voice/ASR)')
platformMessage.value = `获取并保存成功!文本模型: ${chatModel.modelName}` +
`${visionModel ? ',图片识别: ' + visionModel.modelName : ',图片识别复用文本模型'}` +
`${embeddingModel ? ',向量: ' + embeddingModel.modelName : ''}` +
`${rerankModel ? ',重排: ' + rerankModel.modelName : ''}` +
`${voiceModel ? ',语音: ' + voiceModel.modelName : ''}` +
rebuildNote +
`${missing.length ? '。仍需手动配置:' + missing.join('、') : '。全部模型已就绪'}`
platformMessageType.value = rebuildNote.includes('失败') || rebuildNote.includes('异常') ? 'error' : 'success'
} catch (err) {
const errMsg = err.message || String(err)
LogFrontend('error', `[万川平台] 获取失败: ${errMsg}`)
platformMessage.value = `获取失败: ${errMsg}`
platformMessageType.value = 'error'
} finally {
platformBusy.value = false
busy.value = false
}
}
// 解析模型配置
function parseModelConfig(data, code) {
try {
if (!data || !data.data || !data.data.providerModels) {
throw new Error(`平台未返回模型[${code}]配置`)
}
const pm = data.data.providerModels
const modelName = pm.modelName
// encryptedConfig 是 JSON 字符串,需要二次解析
let config = {}
if (pm.encryptedConfig) {
config = JSON.parse(pm.encryptedConfig)
}
return {
modelName: modelName,
apiKey: config.apiKey || '',
endpointUrl: config.endpointUrl || ''
}
} catch (err) {
LogFrontend('error', `[万川平台] 解析模型[${code}]失败: ${err.message || err}`)
return null
}
}
// 重置:重新从平台拉取模型并覆盖回填(用于手动改过模型字段后恢复平台值)
async function resetPlatformConfig() {
if (!platformForm.baseUrl || !platformForm.username || !platformForm.password) {
platformMessage.value = '请先填写平台地址、账号和密码'
platformMessageType.value = 'error'
return
}
LogFrontend('info', '[万川平台] 重置:重新登录并拉取模型覆盖回填')
await loginAndFetchModels()
}
async function loadStatus() {
try {
const result = await GetAutoReplyStatus()
@@ -2105,7 +2453,21 @@ async function rebuildKnowledge() {
try {
if (!(await saveConfig('knowledge', true))) return
const result = await RebuildKnowledgeIndex()
notify(result?.success ? '知识库索引已重建' : `重建失败: ${result?.message || ''}`, result?.success ? 'success' : 'error', 'knowledge')
if (result?.success) {
// 回显扫描到的文件数/分片数0 个文件多半是知识目录放错或文件在未支持的格式里,
// 此时给出醒目提示而不是笼统的“已重建”,避免“点了没反应”的误解。
const fileCount = Number(result?.data?.fileCount ?? 0)
const chunkCount = Number(result?.data?.chunkCount ?? 0)
const failedCount = (result?.data?.failedFiles || []).length
if (fileCount === 0) {
notify('知识库索引已重建,但未扫描到任何知识文件,请确认知识目录和文件格式是否正确。', 'error', 'knowledge')
} else {
const failedSuffix = failedCount > 0 ? `${failedCount} 个文件解析失败` : ''
notify(`知识库索引已重建:${fileCount} 个文件、${chunkCount} 个分片${failedSuffix}`, failedCount > 0 ? 'error' : 'success', 'knowledge')
}
} else {
notify(`重建失败: ${result?.message || ''}`, 'error', 'knowledge')
}
await loadStatus()
} catch (err) {
notify(`重建失败: ${err.message || err}`, 'error', 'knowledge')
@@ -2305,6 +2667,7 @@ function scrollToSection(id) {
onMounted(async () => {
await loadConfig()
await loadPlatformConfig()
await loadStatus()
await loadIdentityOptions(true)
await loadGroupOptions(true)
@@ -2579,6 +2942,45 @@ button:disabled {
line-height: 1.45;
}
.wanchuan-platform-card {
background: #fff8e1;
border-color: #ffd54f;
}
.wanchuan-platform-card .config-subsection-header strong {
color: #f57c00;
}
/* 平台行:三个输入框 + 按钮组同排,按钮靠右并与输入框底部对齐 */
.wanchuan-form-row {
grid-template-columns: minmax(220px, 1.4fr) repeat(2, minmax(140px, 1fr)) auto;
align-items: end;
margin-bottom: 0;
}
.wanchuan-actions {
display: flex;
gap: 10px;
align-items: center;
white-space: nowrap;
}
.wanchuan-actions .ghost-btn {
flex-shrink: 0;
}
/* 窄屏回退:列数不够时按钮组换行但仍靠右 */
@media (max-width: 720px) {
.wanchuan-form-row {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.wanchuan-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
.advanced-toggle {
margin-top: 12px;
min-height: 32px;

View File

@@ -193,7 +193,7 @@ if (typeof window.go === 'undefined') {
visionModel: 'qwen3-vl-plus',
timeoutSeconds: 20,
enableThinking: false,
replyDetail: 'detailed',
replyDetail: 'medium',
temperature: 0,
maxTokens: 700
},

View File

@@ -46,6 +46,8 @@ export function GetKingdeeMonitorStatus():Promise<any>;
export function GetPendingAfterSalesArchiveSummary():Promise<any>;
export function GetPlatformConfig():Promise<any>;
export function GetSystemMemoryUsage():Promise<number>;
export function GetWxWorkAccountList():Promise<any>;
@@ -90,6 +92,8 @@ export function SaveIssue(arg1:main.AfterSalesIssue):Promise<boolean|string>;
export function SaveKingdeeMonitorConfig(arg1:string):Promise<boolean|string>;
export function SavePlatformConfig(arg1:string):Promise<boolean|string>;
export function SendWxWorkData(arg1:string,arg2:string):Promise<boolean>;
export function SetAutoCollectTask(arg1:boolean):Promise<boolean|string>;
@@ -113,3 +117,7 @@ export function TestKingdeeMonitorConnection(arg1:string):Promise<any>;
export function TriggerManualCollect(arg1:string):Promise<boolean|string>;
export function UpdateAfterSalesKnowledgeCase(arg1:string,arg2:string):Promise<any>;
export function WanchuanGetModel(arg1:string,arg2:string,arg3:string):Promise<string>;
export function WanchuanLogin(arg1:string,arg2:string,arg3:string):Promise<string>;

View File

@@ -90,6 +90,10 @@ export function GetPendingAfterSalesArchiveSummary() {
return window['go']['main']['App']['GetPendingAfterSalesArchiveSummary']();
}
export function GetPlatformConfig() {
return window['go']['main']['App']['GetPlatformConfig']();
}
export function GetSystemMemoryUsage() {
return window['go']['main']['App']['GetSystemMemoryUsage']();
}
@@ -178,6 +182,10 @@ export function SaveKingdeeMonitorConfig(arg1) {
return window['go']['main']['App']['SaveKingdeeMonitorConfig'](arg1);
}
export function SavePlatformConfig(arg1) {
return window['go']['main']['App']['SavePlatformConfig'](arg1);
}
export function SendWxWorkData(arg1, arg2) {
return window['go']['main']['App']['SendWxWorkData'](arg1, arg2);
}
@@ -225,3 +233,11 @@ export function TriggerManualCollect(arg1) {
export function UpdateAfterSalesKnowledgeCase(arg1, arg2) {
return window['go']['main']['App']['UpdateAfterSalesKnowledgeCase'](arg1, arg2);
}
export function WanchuanGetModel(arg1, arg2, arg3) {
return window['go']['main']['App']['WanchuanGetModel'](arg1, arg2, arg3);
}
export function WanchuanLogin(arg1, arg2, arg3) {
return window['go']['main']['App']['WanchuanLogin'](arg1, arg2, arg3);
}

View File

@@ -191,6 +191,12 @@ func (e *AutoReplyEngine) processJob(job AutoReplyJob) {
return
}
e.rememberUserMessage(msg)
if isPureTopicSwitchMessage(msg.Content) {
if err := e.replyTextWithTimings(msg, topicSwitchGuidanceAnswer(), "topic_switch_guidance", nil, currentTimings()); err != nil {
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "topic switch reply failed: "+err.Error())
}
return
}
if answer, ok := greetingAnswer(msg.Content); ok {
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, answer); err != nil {
e.handoffWithTimings(msg, "send_greeting_failed: "+err.Error(), nil, currentTimings())

View File

@@ -46,7 +46,7 @@ func (e *AutoReplyEngine) getConfig() config.AutoReplyConfig {
cfg.AI.MaxTokens = 700
}
if strings.TrimSpace(cfg.AI.ReplyDetail) == "" {
cfg.AI.ReplyDetail = "detailed"
cfg.AI.ReplyDetail = "medium"
}
if cfg.Knowledge.TopK <= 0 {
cfg.Knowledge.TopK = 3
@@ -69,8 +69,8 @@ func (e *AutoReplyEngine) askAI(question string, hits []KnowledgeChunk, msg auto
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildAutoReplySystemPrompt(cfg)
msg.ContextText = e.recentContextPrompt(msg, 6)
userPrompt := buildAutoReplyUserPrompt(question, hits, msg, cfg.ReplyPolicy.UnknownAnswerToken)
msg.ContextText = e.contextPromptForQuestion(question, msg)
userPrompt := buildAutoReplyUserPrompt(question, hits, msg, cfg.ReplyPolicy.UnknownAnswerToken, cfg)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
return callOllamaChat(cfg.AI, systemPrompt, userPrompt)
@@ -88,7 +88,7 @@ func (e *AutoReplyEngine) askGeneralAI(question string, msg autoReplyMessage) (*
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildGeneralAutoReplySystemPrompt(cfg)
msg.ContextText = e.recentContextPrompt(msg, 6)
msg.ContextText = e.contextPromptForQuestion(question, msg)
userPrompt := buildGeneralAutoReplyUserPrompt(question, msg)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
@@ -137,7 +137,7 @@ func buildAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
if token == "" {
token = "NO_ANSWER"
}
return prependAISystemPrompt(cfg, "你是企业微信客服。请基于提供的知识库片段,用自然亲切的语气回答客户问题。"+replyDetailInstruction(cfg)+"如果知识库里有详细内容,请完整展开说明,不要只列标题。知识库不足以确定答案时,只输出 "+token+"。不要编造政策、价格、承诺、库存或物流时效。客户要求人工、投诉、退款、合同、发票、赔偿或价格特殊审批时,也只输出 "+token+"。")
return prependAISystemPrompt(cfg, "你是企业微信客服。请基于提供的知识库片段,用自然亲切的语气回答客户问题。"+replyDetailInstruction(cfg)+knowledgeCompletenessInstruction(cfg)+"知识库不足以确定答案时,只输出 "+token+"。不要编造政策、价格、承诺、库存或物流时效。客户要求人工、投诉、退款、合同、发票、赔偿或价格特殊审批时,也只输出 "+token+"。")
}
func buildGeneralAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
@@ -184,12 +184,26 @@ func replyDetailInstruction(cfg config.AutoReplyConfig) string {
case "concise":
return "回复简洁直接1-2句话说清楚核心内容即可。"
case "medium":
return "回复适度详细2-4句话说明关键信息和注意事项。"
return "回复适度详细2-4句话说明关键信息和注意事项,不要罗列大段条目。"
default:
return "回复详细充分,把知识库的相关内容完整说清楚,让客户能理解具体情况。语气要自然,像真人对话一样,不要用模板化的官方表达。"
}
}
// knowledgeCompletenessInstruction 控制"知识库片段要展开到多细"。
// 这条指令必须与 replyDetailInstruction 一致,否则会出现"选了中等却仍写长文"的矛盾:
// detailed 才要求完整展开concise/medium 只挑与问题最相关的部分作答,避免又慢又被 max_tokens 截断。
func knowledgeCompletenessInstruction(cfg config.AutoReplyConfig) string {
switch strings.ToLower(strings.TrimSpace(cfg.AI.ReplyDetail)) {
case "concise":
return "只回答客户这一句问的内容,挑知识库里最相关的一点说清楚,不要把整段资料都搬出来。"
case "medium":
return "只针对客户当前的问题作答,从知识库里挑最相关的关键信息,不要把不相关的条目也一并列出。"
default:
return "如果知识库里有详细内容,请完整展开说明,不要只列标题。"
}
}
func effectiveReplyMaxTokens(cfg config.AIConfig) int {
maxTokens := cfg.MaxTokens
switch strings.ToLower(strings.TrimSpace(cfg.ReplyDetail)) {
@@ -220,7 +234,7 @@ func buildGeneralAutoReplyUserPrompt(question string, msg autoReplyMessage) stri
b.WriteString("\n客户问题")
b.WriteString(question)
if contextText := strings.TrimSpace(msg.ContextText); contextText != "" {
b.WriteString("\n\n最近对话上下文\n")
b.WriteString("\n\n最近对话上下文(仅供理解称呼和承接,请只回答“客户问题”那一句,不要主动延续之前的话题)\n")
b.WriteString(contextText)
}
b.WriteString("\n请直接给客户一条友好、可发送的回复。")
@@ -253,7 +267,7 @@ func buildNonTextAutoReplyUserPrompt(msg autoReplyMessage) string {
return b.String()
}
func buildAutoReplyUserPrompt(question string, hits []KnowledgeChunk, msg autoReplyMessage, noAnswerToken string) string {
func buildAutoReplyUserPrompt(question string, hits []KnowledgeChunk, msg autoReplyMessage, noAnswerToken string, cfg config.AutoReplyConfig) string {
noAnswerToken = strings.TrimSpace(noAnswerToken)
if noAnswerToken == "" {
noAnswerToken = "NO_ANSWER"
@@ -268,14 +282,16 @@ func buildAutoReplyUserPrompt(question string, hits []KnowledgeChunk, msg autoRe
b.WriteString("\n客户问题")
b.WriteString(question)
if contextText := strings.TrimSpace(msg.ContextText); contextText != "" {
b.WriteString("\n\n最近对话上下文\n")
b.WriteString("\n\n最近对话上下文(仅供理解称呼和承接,请只回答“客户问题”那一句,不要主动延续之前的话题)\n")
b.WriteString(contextText)
}
b.WriteString("\n\n知识库片段\n")
for i, hit := range compactKnowledgeHitsForAI(hits) {
b.WriteString(fmt.Sprintf("[%d] 来源:%s 分数:%.3f\n%s\n\n", i+1, hit.Source, hit.Score, hit.Content))
}
b.WriteString("请基于上面的知识库片段回答客户问题。如果片段中有详细说明(比如具体步骤、标准、要求等),请完整地告诉客户,不要只列出标题。用自然的口语化表达,避免生硬的书面语。")
b.WriteString("请基于上面的知识库片段回答客户问题。")
b.WriteString(knowledgeCompletenessInstruction(cfg))
b.WriteString("用自然的口语化表达,避免生硬的书面语。")
if isGenericProductQuery(question) {
b.WriteString("如果客户询问全部产品、产品线或产品总览,请根据片段中能确定的内容整理产品/产品线清单只列能确定的产品不要说“knowledge库”“根据知识库”“知识库内容无法确定具体产品”不要输出空的 Markdown 列表或连续星号。")
}

View File

@@ -1,902 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"qiweimanager/config"
)
type AIResult struct {
Answer string `json:"answer"`
RawSummary string `json:"rawSummary"`
DurationMS int64 `json:"durationMs"`
}
const (
aiPromptMaxHits = 8 // 增加到8个片段提供更多上下文
aiPromptMaxChunkRunes = 1200 // 增加到1200字保留更多细节
aiPromptMaxContextRune = 8000 // 增加到8000字支持更长的知识库内容
defaultAudioModel = "qwen3-asr-flash"
audioModeAuto = "auto"
audioModeOpenAIChat = "openai_audio_chat"
audioModeParaformer = "dashscope_paraformer"
audioModeTranscription = "local_openai_transcription"
audioModeCustomHTTP = "custom_http"
)
func (e *AutoReplyEngine) getConfig() config.AutoReplyConfig {
e.mu.Lock()
defer e.mu.Unlock()
cfg := e.config
if cfg.AI.TimeoutSeconds <= 0 {
cfg.AI.TimeoutSeconds = 20
}
if cfg.AI.MaxTokens <= 0 {
cfg.AI.MaxTokens = 700
}
if strings.TrimSpace(cfg.AI.ReplyDetail) == "" {
cfg.AI.ReplyDetail = "detailed"
}
if cfg.Knowledge.TopK <= 0 {
cfg.Knowledge.TopK = 3
}
if cfg.Knowledge.MinScore <= 0 {
cfg.Knowledge.MinScore = 0.40
}
if cfg.ReplyPolicy.UnknownAnswerToken == "" {
cfg.ReplyPolicy.UnknownAnswerToken = "NO_ANSWER"
}
return cfg
}
func (e *AutoReplyEngine) askAI(question string, hits []KnowledgeChunk, msg autoReplyMessage) (*AIResult, error) {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
return nil, fmt.Errorf("AI Base URL未配置")
}
if strings.TrimSpace(cfg.AI.Model) == "" {
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildAutoReplySystemPrompt(cfg)
msg.ContextText = e.recentContextPrompt(msg, 6)
userPrompt := buildAutoReplyUserPrompt(question, hits, msg, cfg.ReplyPolicy.UnknownAnswerToken)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
return callOllamaChat(cfg.AI, systemPrompt, userPrompt)
default:
return callOpenAICompatibleChat(cfg.AI, systemPrompt, userPrompt)
}
}
func (e *AutoReplyEngine) askGeneralAI(question string, msg autoReplyMessage) (*AIResult, error) {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
return nil, fmt.Errorf("AI Base URL未配置")
}
if strings.TrimSpace(cfg.AI.Model) == "" {
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildGeneralAutoReplySystemPrompt(cfg)
msg.ContextText = e.recentContextPrompt(msg, 6)
userPrompt := buildGeneralAutoReplyUserPrompt(question, msg)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
return callOllamaChat(cfg.AI, systemPrompt, userPrompt)
default:
return callOpenAICompatibleChat(cfg.AI, systemPrompt, userPrompt)
}
}
func (e *AutoReplyEngine) askNonTextAI(msg autoReplyMessage) (*AIResult, error) {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
return nil, fmt.Errorf("AI Base URL未配置")
}
if strings.TrimSpace(cfg.AI.Model) == "" {
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildNonTextAutoReplySystemPrompt(cfg)
userPrompt := buildNonTextAutoReplyUserPrompt(msg)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
return callOllamaChat(cfg.AI, systemPrompt, userPrompt)
default:
if mediaURL := strings.TrimSpace(msg.MediaURL); mediaURL != "" {
return callOpenAICompatibleVisionChat(cfg.AI, systemPrompt, userPrompt, mediaURL)
}
return callOpenAICompatibleChat(cfg.AI, systemPrompt, userPrompt)
}
}
func (e *AutoReplyEngine) testAIConnection() (*AIResult, error) {
testMsg := autoReplyMessage{
FromNickName: "测试客户",
ConversationID: "test",
}
hits := []KnowledgeChunk{{
Source: "test.md",
Content: "测试知识:自动客服连接测试时,请回复“连接正常”。",
Score: 1,
}}
return e.askAI("请回复连接正常", hits, testMsg)
}
func buildAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
token := cfg.ReplyPolicy.UnknownAnswerToken
if token == "" {
token = "NO_ANSWER"
}
return prependAISystemPrompt(cfg, "你是企业微信售后客服助手。只能根据提供的知识库片段回答客户问题。"+replyDetailInstruction(cfg)+"知识库不足以确定答案时,只输出 "+token+"。不要编造政策、价格、承诺、库存或物流时效。客户要求人工、投诉、退款、合同、发票、赔偿或价格特殊审批时,也只输出 "+token+"。")
}
func buildGeneralAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
token := cfg.ReplyPolicy.UnknownAnswerToken
if token == "" {
token = "NO_ANSWER"
}
return prependAISystemPrompt(cfg, "你是企业微信智能客服助手。请用中文自然、和蔼地回答普通问候、身份介绍和日常沟通问题。"+replyDetailInstruction(cfg)+"不要冒充真人,不要编造产品参数、价格、政策、库存、物流、合同、发票或售后结论。遇到需要公司专有资料、知识库、人工审批或无法确认的信息时,不要硬编,可以温和说明会按资料核对或请客户补充具体问题。不要输出 "+token+",除非客户明确要求停止回复。")
}
func buildNonTextAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
return prependAISystemPrompt(cfg, "你是企业微信客服岗位助手。用户发来非文本消息时,请根据消息类型和文字描述判断是否属于客服岗位可处理范围。范围内包括产品咨询、订单、售后、方案资料、使用问题、客户服务沟通;可回复时要自然、和蔼。"+replyDetailInstruction(cfg)+"不要编造图片里不存在的信息。若无法判断图片/表情内容,礼貌请客户补充文字说明。若明显超出客服岗位范围,只能回复:抱歉,你这问题超出我的岗位认知了,回答不了。不要主动转人工,除非客户明确要求人工。")
}
func buildVisionRecognitionSystemPrompt(cfg config.AutoReplyConfig) string {
return prependAISystemPrompt(cfg, "你是企业微信客服岗位的图片识别助手。请识别客户发来的图片/表情/封面中与客服沟通有关的内容,输出一句简洁中文描述;如果明显不是客服岗位可处理的内容,也请说明其大概内容。不要编造看不见的信息。")
}
func prependAISystemPrompt(cfg config.AutoReplyConfig, base string) string {
identity := strings.TrimSpace(cfg.AI.SystemPrompt)
if identity == "" {
identity = "你是一名企业微信智能客服。"
}
return identity + "\n" + base
}
func replyDetailInstruction(cfg config.AutoReplyConfig) string {
switch strings.ToLower(strings.TrimSpace(cfg.AI.ReplyDetail)) {
case "concise":
return "回复保持简洁通常1-2句约80-140个中文字符先回答结论必要时补一句下一步建议。"
case "medium":
return "回复详细程度适中通常2-4句约160-280个中文字符先回答结论再说明关键原因或注意事项最后给出下一步建议。"
default:
return "回复尽量详细但不要啰嗦通常3-6句约280-500个中文字符先明确回答客户问题再结合可用资料说明关键点、适用场景或限制最后给出具体下一步建议。"
}
}
func effectiveReplyMaxTokens(cfg config.AIConfig) int {
maxTokens := cfg.MaxTokens
switch strings.ToLower(strings.TrimSpace(cfg.ReplyDetail)) {
case "concise":
if maxTokens < 220 {
return 220
}
case "medium":
if maxTokens < 450 {
return 450
}
default:
if maxTokens < 700 {
return 700
}
}
return maxTokens
}
func buildGeneralAutoReplyUserPrompt(question string, msg autoReplyMessage) string {
var b strings.Builder
b.WriteString("客户昵称:")
if msg.FromNickName != "" {
b.WriteString(msg.FromNickName)
} else {
b.WriteString("未知")
}
b.WriteString("\n客户问题")
b.WriteString(question)
if contextText := strings.TrimSpace(msg.ContextText); contextText != "" {
b.WriteString("\n\n最近对话上下文\n")
b.WriteString(contextText)
}
b.WriteString("\n请直接给客户一条友好、可发送的回复。")
return b.String()
}
func buildNonTextAutoReplyUserPrompt(msg autoReplyMessage) string {
var b strings.Builder
b.WriteString("客户昵称:")
if msg.FromNickName != "" {
b.WriteString(msg.FromNickName)
} else {
b.WriteString("未知")
}
b.WriteString("\n消息类型")
b.WriteString(msg.MessageType)
b.WriteString("\n原始类型")
b.WriteString(fmt.Sprintf("%d", msg.RawType))
b.WriteString("\n消息描述")
if strings.TrimSpace(msg.Content) != "" {
b.WriteString(msg.Content)
} else {
b.WriteString("无文字描述")
}
if strings.TrimSpace(msg.MediaURL) != "" {
b.WriteString("\n媒体地址")
b.WriteString(msg.MediaURL)
}
b.WriteString("\n请直接给客户一条可发送的回复。")
return b.String()
}
func buildAutoReplyUserPrompt(question string, hits []KnowledgeChunk, msg autoReplyMessage, noAnswerToken string) string {
noAnswerToken = strings.TrimSpace(noAnswerToken)
if noAnswerToken == "" {
noAnswerToken = "NO_ANSWER"
}
var b strings.Builder
b.WriteString("客户昵称:")
if msg.FromNickName != "" {
b.WriteString(msg.FromNickName)
} else {
b.WriteString("未知")
}
b.WriteString("\n客户问题")
b.WriteString(question)
if contextText := strings.TrimSpace(msg.ContextText); contextText != "" {
b.WriteString("\n\n最近对话上下文\n")
b.WriteString(contextText)
}
b.WriteString("\n\n知识库片段\n")
for i, hit := range compactKnowledgeHitsForAI(hits) {
b.WriteString(fmt.Sprintf("[%d] 来源:%s 分数:%.3f\n%s\n\n", i+1, hit.Source, hit.Score, hit.Content))
}
b.WriteString("\u53ea\u80fd\u4f7f\u7528\u4e0a\u9762\u7247\u6bb5\u4e2d\u660e\u786e\u51fa\u73b0\u7684\u4e8b\u5b9e\u56de\u7b54\uff1b\u5982\u679c\u8be2\u95ee\u90e8\u95e8\u3001\u4f1a\u8bae\u65f6\u95f4\u3001\u6807\u51c6\u6216\u89c4\u5b9a\uff0c\u53ea\u80fd\u5217\u51fa\u7247\u6bb5\u91cc\u76f4\u63a5\u51fa\u73b0\u7684\u503c\uff0c\u4e0d\u5f97\u6839\u636e\u5e38\u8bc6\u8865\u5145\u5176\u4ed6\u90e8\u95e8\u6216\u65f6\u95f4\u3002\n")
if isGenericProductQuery(question) {
b.WriteString("客户在泛问产品时请优先按知识库列出具体产品或型号每项用一句话说明定位最后询问客户更关注硬件、模型还是AI应用。不要只概括为几大类。无法确认时只输出 ")
} else {
b.WriteString("请基于知识库片段回答客户。无法确认时只输出 ")
}
b.WriteString(noAnswerToken)
b.WriteString("。")
return b.String()
}
func compactKnowledgeHitsForAI(hits []KnowledgeChunk) []KnowledgeChunk {
if len(hits) == 0 {
return nil
}
limit := aiPromptMaxHits
if len(hits) < limit {
limit = len(hits)
}
result := make([]KnowledgeChunk, 0, limit)
totalRunes := 0
for i := 0; i < limit; i++ {
hit := hits[i]
content := strings.TrimSpace(hit.Content)
if content == "" {
continue
}
content = truncateTextForPrompt(content, aiPromptMaxChunkRunes)
remaining := aiPromptMaxContextRune - totalRunes
if remaining <= 0 {
break
}
if len([]rune(content)) > remaining {
content = truncateTextForPrompt(content, remaining)
}
hit.Content = content
totalRunes += len([]rune(content))
result = append(result, hit)
}
return result
}
func truncateTextForPrompt(text string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= max {
return text
}
return string(runes[:max])
}
func callOpenAICompatibleChat(cfg config.AIConfig, systemPrompt string, userPrompt string) (*AIResult, error) {
url := strings.TrimRight(cfg.BaseURL, "/")
if !strings.HasSuffix(url, "/chat/completions") {
url += "/chat/completions"
}
payload := map[string]interface{}{
"model": cfg.Model,
"temperature": cfg.Temperature,
"max_tokens": effectiveReplyMaxTokens(cfg),
"enable_thinking": cfg.EnableThinking,
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
}
var response struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error interface{} `json:"error"`
}
result, err := doAIJSONRequest(cfg, url, payload, &response)
if err != nil {
return nil, err
}
if response.Error != nil {
return nil, fmt.Errorf("AI返回错误: %v", response.Error)
}
if len(response.Choices) == 0 {
return nil, fmt.Errorf("AI返回空choices")
}
answer := strings.TrimSpace(response.Choices[0].Message.Content)
result.Answer = answer
result.RawSummary = truncateText(answer, 160)
return result, nil
}
func callOpenAICompatibleVisionChat(cfg config.AIConfig, systemPrompt string, userPrompt string, imageURL string) (*AIResult, error) {
visionCfg := visionRequestConfig(cfg)
url := strings.TrimRight(visionCfg.BaseURL, "/")
if !strings.HasSuffix(url, "/chat/completions") {
url += "/chat/completions"
}
payload := map[string]interface{}{
"model": visionCfg.Model,
"temperature": visionCfg.Temperature,
"max_tokens": visionCfg.MaxTokens,
"enable_thinking": visionCfg.EnableThinking,
"messages": []map[string]interface{}{
{"role": "system", "content": systemPrompt},
{
"role": "user",
"content": []map[string]interface{}{
{"type": "text", "text": userPrompt},
{"type": "image_url", "image_url": map[string]string{"url": imageURL}},
},
},
},
}
var response struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error interface{} `json:"error"`
}
result, err := doAIJSONRequest(visionCfg, url, payload, &response)
if err != nil {
return nil, err
}
if response.Error != nil {
return nil, fmt.Errorf("AI返回错误: %v", response.Error)
}
if len(response.Choices) == 0 {
return nil, fmt.Errorf("AI返回空choices")
}
answer := strings.TrimSpace(response.Choices[0].Message.Content)
result.Answer = answer
result.RawSummary = truncateText(answer, 160)
return result, nil
}
func visionRequestConfig(cfg config.AIConfig) config.AIConfig {
visionCfg := cfg
visionCfg.Model = fallbackString(cfg.VisionModel, cfg.Model)
if strings.TrimSpace(cfg.VisionBaseURL) != "" {
visionCfg.BaseURL = strings.TrimSpace(cfg.VisionBaseURL)
}
visionKey := strings.TrimSpace(cfg.VisionAPIKey)
if visionKey != "" && !looksLikeURL(visionKey) {
visionCfg.APIKey = visionKey
}
return visionCfg
}
func callOpenAICompatibleAudioChatTranscription(cfg config.AIConfig, audioPath string) (string, error) {
audioCfg := audioRequestConfig(cfg)
audioDataURL, err := audioDataURLFromFile(audioPath)
if err != nil {
return "", err
}
url := strings.TrimRight(audioCfg.BaseURL, "/")
if !strings.HasSuffix(url, "/chat/completions") {
url += "/chat/completions"
}
model := fallbackString(audioCfg.Model, defaultAudioModel)
payload := map[string]interface{}{
"model": model,
"temperature": 0,
"max_tokens": audioCfg.MaxTokens,
"enable_thinking": false,
"messages": []map[string]interface{}{
{
"role": "user",
"content": audioChatContentForModel(model, audioDataURL),
},
},
}
var response struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error interface{} `json:"error"`
}
if _, err := doAIJSONRequest(audioCfg, url, payload, &response); err != nil {
return "", fmt.Errorf("audio chat transcription failed (model=%s endpoint=%s): %w", audioCfg.Model, url, err)
}
if response.Error != nil {
return "", fmt.Errorf("audio chat transcription failed (model=%s endpoint=%s): %v", audioCfg.Model, url, response.Error)
}
if len(response.Choices) == 0 {
return "", fmt.Errorf("audio chat transcription failed (model=%s endpoint=%s): empty choices", audioCfg.Model, url)
}
text := strings.TrimSpace(response.Choices[0].Message.Content)
if text == "" {
return "", fmt.Errorf("audio chat transcription failed (model=%s endpoint=%s): empty text", audioCfg.Model, url)
}
return text, nil
}
func audioChatContentForModel(model string, audioDataURL string) []map[string]interface{} {
if isQwenASRModel(model) {
return []map[string]interface{}{
{"type": "input_audio", "input_audio": audioDataURL},
}
}
return []map[string]interface{}{
{"type": "text", "text": "请把这段语音转写成简体中文文本,只输出转写内容,不要解释。"},
{"type": "input_audio", "input_audio": map[string]interface{}{"data": audioDataURL}},
}
}
func isQwenASRModel(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
return strings.HasPrefix(name, "qwen3-asr") || strings.HasPrefix(name, "qwen-asr")
}
func audioRequestConfig(cfg config.AIConfig) config.AIConfig {
audioCfg := cfg
audioCfg.Model = fallbackString(cfg.AudioModel, defaultAudioModel)
if strings.TrimSpace(cfg.AudioBaseURL) != "" {
audioCfg.BaseURL = strings.TrimSpace(cfg.AudioBaseURL)
}
audioKey := strings.TrimSpace(cfg.AudioAPIKey)
if audioKey != "" && !looksLikeURL(audioKey) {
audioCfg.APIKey = audioKey
}
audioCfg.EnableThinking = false
audioCfg.Temperature = 0
return audioCfg
}
func audioConfigWarning(cfg config.AIConfig) string {
if looksLikeURL(strings.TrimSpace(cfg.AudioAPIKey)) {
return "语音 API Key 误填为 URL已忽略该值并复用主 API Key"
}
return ""
}
func inferAudioMode(cfg config.AIConfig) string {
mode := normalizeAudioMode(cfg.AudioMode)
if mode != audioModeAuto {
return mode
}
provider := normalizeAudioMode(cfg.AudioProvider)
if provider != audioModeAuto {
return provider
}
model := strings.ToLower(strings.TrimSpace(cfg.AudioModel))
if strings.HasPrefix(model, "paraformer") {
return audioModeParaformer
}
if strings.Contains(model, "whisper") || strings.Contains(model, "transcribe") {
return audioModeTranscription
}
return audioModeOpenAIChat
}
func normalizeAudioMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", audioModeAuto:
return audioModeAuto
case "openai", "openai_chat", "audio_chat", "qwen_audio", "qwen3_asr", audioModeOpenAIChat:
return audioModeOpenAIChat
case "dashscope", "paraformer", audioModeParaformer:
return audioModeParaformer
case "transcription", "openai_transcription", "local", "local_asr", audioModeTranscription:
return audioModeTranscription
case "custom", audioModeCustomHTTP:
return audioModeCustomHTTP
default:
return audioModeAuto
}
}
func looksLikeURL(value string) bool {
value = strings.TrimSpace(value)
return strings.HasPrefix(strings.ToLower(value), "http://") || strings.HasPrefix(strings.ToLower(value), "https://")
}
func supportsSilkDirectly(cfg config.AIConfig) bool {
model := strings.ToLower(strings.TrimSpace(cfg.AudioModel))
mode := inferAudioMode(cfg)
if mode == audioModeParaformer || mode == audioModeTranscription || mode == audioModeCustomHTTP {
return false
}
return strings.Contains(model, "silk")
}
func dashScopeAPIBaseURL(cfg config.AIConfig) string {
base := strings.TrimSpace(cfg.AudioBaseURL)
if base == "" {
base = strings.TrimSpace(cfg.BaseURL)
}
if base == "" || strings.Contains(base, "/compatible-mode/") {
return "https://dashscope.aliyuncs.com/api/v1"
}
base = strings.TrimRight(base, "/")
if strings.HasSuffix(base, "/services/audio/asr/transcription") {
return strings.TrimSuffix(base, "/services/audio/asr/transcription")
}
if strings.Contains(base, "/api/v1/") {
return strings.Split(base, "/api/v1/")[0] + "/api/v1"
}
if strings.HasSuffix(base, "/api/v1") {
return base
}
return base
}
func callOpenAICompatibleAudioTranscription(cfg config.AIConfig, audioPath string) (string, error) {
cfg = audioRequestConfig(cfg)
url := strings.TrimRight(cfg.BaseURL, "/")
if !strings.HasSuffix(url, "/audio/transcriptions") {
url += "/audio/transcriptions"
}
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
file, err := os.Open(audioPath)
if err != nil {
return "", err
}
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if err := writer.WriteField("model", cfg.Model); err != nil {
return "", err
}
part, err := writer.CreateFormFile("file", filepath.Base(audioPath))
if err != nil {
return "", err
}
if _, err := io.Copy(part, file); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", url, body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
if strings.TrimSpace(cfg.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.APIKey))
}
resp, err := (&http.Client{Timeout: timeout}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("audio transcription failed (model=%s endpoint=%s): HTTP status %d, body=%s", cfg.Model, url, resp.StatusCode, truncateText(string(respBody), 240))
}
var parsed struct {
Text string `json:"text"`
Error interface{} `json:"error"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return "", fmt.Errorf("parse audio transcription failed (model=%s endpoint=%s): %v, body=%s", cfg.Model, url, err, truncateText(string(respBody), 240))
}
if parsed.Error != nil {
return "", fmt.Errorf("audio transcription failed (model=%s endpoint=%s): %v", cfg.Model, url, parsed.Error)
}
text := strings.TrimSpace(parsed.Text)
if text == "" {
return "", fmt.Errorf("audio transcription failed (model=%s endpoint=%s): empty text", cfg.Model, url)
}
return text, nil
}
func callDashScopeParaformerTranscription(cfg config.AIConfig, fileURL string) (string, error) {
cfg = audioRequestConfig(cfg)
fileURL = strings.TrimSpace(fileURL)
if fileURL == "" {
return "", fmt.Errorf("paraformer transcription failed (model=%s): 需要公网可访问的音频 URL本地文件不能直接提交给 Paraformer RESTful 接口", cfg.Model)
}
parsedURL, err := url.Parse(fileURL)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https" && parsedURL.Scheme != "oss") {
return "", fmt.Errorf("paraformer transcription failed (model=%s): 音频 URL 无效", cfg.Model)
}
base := dashScopeAPIBaseURL(cfg)
submitURL := strings.TrimRight(base, "/") + "/services/audio/asr/transcription"
payload := map[string]interface{}{
"model": fallbackString(cfg.Model, "paraformer-v2"),
"input": map[string]interface{}{
"file_urls": []string{fileURL},
},
"parameters": map[string]interface{}{
"channel_id": []int{0},
"language_hints": []string{"zh", "en"},
},
}
var submitResp struct {
Output struct {
TaskID string `json:"task_id"`
TaskStatus string `json:"task_status"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
}
if err := doDashScopeJSONRequest(cfg, submitURL, "POST", payload, true, &submitResp); err != nil {
return "", fmt.Errorf("paraformer transcription submit failed (model=%s endpoint=%s): %w", cfg.Model, submitURL, err)
}
if submitResp.Code != "" || submitResp.Message != "" {
return "", fmt.Errorf("paraformer transcription submit failed (model=%s endpoint=%s): %s %s", cfg.Model, submitURL, submitResp.Code, submitResp.Message)
}
taskID := strings.TrimSpace(submitResp.Output.TaskID)
if taskID == "" {
return "", fmt.Errorf("paraformer transcription submit failed (model=%s endpoint=%s): empty task_id", cfg.Model, submitURL)
}
return waitDashScopeParaformerTask(cfg, base, taskID)
}
func waitDashScopeParaformerTask(cfg config.AIConfig, base string, taskID string) (string, error) {
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
deadline := time.Now().Add(timeout)
queryURL := strings.TrimRight(base, "/") + "/tasks/" + url.PathEscape(taskID)
var lastStatus string
for time.Now().Before(deadline) {
var queryResp struct {
Output struct {
TaskStatus string `json:"task_status"`
Results []struct {
FileURL string `json:"file_url"`
TranscriptionURL string `json:"transcription_url"`
SubtaskStatus string `json:"subtask_status"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"results"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
}
if err := doDashScopeJSONRequest(cfg, queryURL, "GET", nil, false, &queryResp); err != nil {
return "", fmt.Errorf("paraformer transcription query failed (model=%s endpoint=%s task=%s): %w", cfg.Model, queryURL, taskID, err)
}
if queryResp.Code != "" || queryResp.Message != "" {
return "", fmt.Errorf("paraformer transcription query failed (model=%s endpoint=%s task=%s): %s %s", cfg.Model, queryURL, taskID, queryResp.Code, queryResp.Message)
}
lastStatus = strings.ToUpper(strings.TrimSpace(queryResp.Output.TaskStatus))
switch lastStatus {
case "SUCCEEDED":
for _, result := range queryResp.Output.Results {
if strings.EqualFold(result.SubtaskStatus, "SUCCEEDED") && strings.TrimSpace(result.TranscriptionURL) != "" {
return downloadDashScopeTranscriptionResult(cfg, result.TranscriptionURL)
}
if result.Code != "" || result.Message != "" {
return "", fmt.Errorf("paraformer transcription subtask failed (model=%s task=%s): %s %s", cfg.Model, taskID, result.Code, result.Message)
}
}
return "", fmt.Errorf("paraformer transcription finished without usable result (model=%s task=%s)", cfg.Model, taskID)
case "FAILED", "CANCELED", "UNKNOWN":
return "", fmt.Errorf("paraformer transcription task failed (model=%s task=%s status=%s)", cfg.Model, taskID, lastStatus)
}
time.Sleep(500 * time.Millisecond)
}
return "", fmt.Errorf("paraformer transcription timed out (model=%s task=%s last_status=%s)", cfg.Model, taskID, lastStatus)
}
func downloadDashScopeTranscriptionResult(cfg config.AIConfig, resultURL string) (string, error) {
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", resultURL, nil)
if err != nil {
return "", err
}
resp, err := (&http.Client{Timeout: timeout}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("download paraformer result failed: HTTP status %d, body=%s", resp.StatusCode, truncateText(string(respBody), 240))
}
var parsed struct {
Transcripts []struct {
Text string `json:"text"`
} `json:"transcripts"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return "", fmt.Errorf("parse paraformer result failed: %v, body=%s", err, truncateText(string(respBody), 240))
}
parts := make([]string, 0, len(parsed.Transcripts))
for _, transcript := range parsed.Transcripts {
if text := strings.TrimSpace(transcript.Text); text != "" {
parts = append(parts, text)
}
}
text := strings.TrimSpace(strings.Join(parts, "\n"))
if text == "" {
return "", fmt.Errorf("paraformer result returned empty text")
}
return text, nil
}
func doDashScopeJSONRequest(cfg config.AIConfig, endpoint string, method string, payload interface{}, async bool, out interface{}) error {
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewBuffer(data)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if async {
req.Header.Set("X-DashScope-Async", "enable")
}
if strings.TrimSpace(cfg.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.APIKey))
}
resp, err := (&http.Client{Timeout: timeout}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP status %d, body=%s", resp.StatusCode, truncateText(string(respBody), 240))
}
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("parse response failed: %v, body=%s", err, truncateText(string(respBody), 240))
}
return nil
}
func callOllamaChat(cfg config.AIConfig, systemPrompt string, userPrompt string) (*AIResult, error) {
url := strings.TrimRight(cfg.BaseURL, "/")
if !strings.HasSuffix(url, "/api/chat") {
url += "/api/chat"
}
payload := map[string]interface{}{
"model": cfg.Model,
"stream": false,
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
"options": map[string]interface{}{
"temperature": cfg.Temperature,
"num_predict": effectiveReplyMaxTokens(cfg),
},
}
var response struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Response string `json:"response"`
Error string `json:"error"`
}
result, err := doAIJSONRequest(cfg, url, payload, &response)
if err != nil {
return nil, err
}
if response.Error != "" {
return nil, fmt.Errorf("本地模型返回错误: %s", response.Error)
}
answer := strings.TrimSpace(response.Message.Content)
if answer == "" {
answer = strings.TrimSpace(response.Response)
}
if answer == "" {
return nil, fmt.Errorf("本地模型返回空内容")
}
result.Answer = answer
result.RawSummary = truncateText(answer, 160)
return result, nil
}
func doAIJSONRequest(cfg config.AIConfig, url string, payload interface{}, out interface{}) (*AIResult, error) {
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(cfg.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.APIKey))
}
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("AI HTTP状态码错误: %d, body=%s", resp.StatusCode, truncateText(string(respBody), 240))
}
if err := json.Unmarshal(respBody, out); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %v, body=%s", err, truncateText(string(respBody), 240))
}
return &AIResult{DurationMS: time.Since(start).Milliseconds()}, nil
}

View File

@@ -149,6 +149,15 @@ func (e *AutoReplyEngine) previousUserQuestion(msg autoReplyMessage) string {
return ""
}
// contextPromptForQuestion 仅在当前问题是指代型追问时,才把历史对话注入 AI prompt。
// 自包含问题(含“换话题”)不带历史,避免模型顺着旧话题继续答、甚至逐字复读上一条。
func (e *AutoReplyEngine) contextPromptForQuestion(question string, msg autoReplyMessage) string {
if !questionReferencesContext(question) {
return ""
}
return e.recentContextPrompt(msg, 6)
}
func (e *AutoReplyEngine) recentContextPrompt(msg autoReplyMessage, maxEntries int) string {
entries := e.contextEntriesForMessage(msg)
if len(entries) == 0 {
@@ -184,14 +193,85 @@ func (e *AutoReplyEngine) recentContextPrompt(msg autoReplyMessage, maxEntries i
}
func (e *AutoReplyEngine) contextualSearchText(question string, msg autoReplyMessage) string {
contextText := e.recentContextPrompt(msg, 6)
question = strings.TrimSpace(question)
// 只有“指代型追问”(如“它多少钱”“刚才那个再说说”)才需要把历史对话拼进检索 query。
// 自包含问题(如“今天星期几”“换个话题”)一旦带上历史,会把上一个话题的知识高分召回,
// 导致顺着旧话题继续答,甚至在 temperature=0 时逐字复读上一条回复。
if !questionReferencesContext(question) {
return question
}
contextText := e.recentContextPrompt(msg, 6)
if contextText == "" {
return question
}
return contextText + "\n当前问题" + question
}
// topicSwitchPhrases 是“客户主动要求换话题”的常见说法(短语本身不含新话题)。
var topicSwitchPhrases = []string{
"换个话题", "换一个话题", "换个问题", "换一个问题", "换话题", "换个方向",
"聊点别的", "说点别的", "聊别的", "说别的", "不聊这个", "不说这个", "别聊这个", "别说这个",
}
// strongAnaphoraTokens 基本只在“追问上文”时出现,命中即视为指代型问题。
var strongAnaphoraTokens = []string{
"它", "它们", "这个", "那个", "这款", "那款", "这种", "那种", "这台", "那台",
"上面", "刚才", "刚刚", "接着", "继续", "还有没有", "还有别的", "展开说",
"详细说", "多说点", "再说说", "具体点", "上一个", "上一条", "前面说", "之前说", "你说的",
}
// weakAnaphoraTokens 是较弱的指代词,仅在很短的问句里才视为追问,
// 避免“今天他要不要来”这类自带主语的完整问题被误判为指代。
var weakAnaphoraTokens = []string{"这", "那", "它", "这是", "那是", "这些", "那些"}
// questionReferencesContext 判断当前问题是否依赖上文(指代型追问)。
func questionReferencesContext(question string) bool {
text := normalizeGreetingText(question)
if text == "" {
return false
}
for _, token := range strongAnaphoraTokens {
if strings.Contains(text, normalizeGreetingText(token)) {
return true
}
}
if len([]rune(text)) <= 8 {
for _, token := range weakAnaphoraTokens {
if strings.Contains(text, normalizeGreetingText(token)) {
return true
}
}
}
return false
}
// isPureTopicSwitchMessage 判断消息是否“只是要求换话题”而没有带上新的问题。
// 这类消息应给一句干净的引导,不能把上一个话题的知识或回复拖过来。
func isPureTopicSwitchMessage(content string) bool {
text := normalizeGreetingText(content)
if text == "" {
return false
}
matched := false
for _, phrase := range topicSwitchPhrases {
normalized := normalizeGreetingText(phrase)
if strings.Contains(text, normalized) {
text = strings.ReplaceAll(text, normalized, "")
matched = true
}
}
if !matched {
return false
}
// 去掉切换短语和常见语气词后,几乎没有剩余内容,才算“纯换话题”。
text = strings.Trim(text, "吧把了啊呀呢嘛吗好的我们咱们来")
return len([]rune(text)) <= 2
}
func topicSwitchGuidanceAnswer() string {
return "好的,那咱们聊点别的。您还想了解些什么,直接发我就行。"
}
func (e *AutoReplyEngine) contextEntriesForMessage(msg autoReplyMessage) []autoReplyContextEntry {
key := e.contextKeyForMessage(msg)
e.mu.Lock()

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"html"
"io"
"io/fs"
"math"
"os"
"os/exec"
@@ -111,19 +112,20 @@ func (e *AutoReplyEngine) rebuildKnowledgeIndex() (*KnowledgeIndex, error) {
if err := os.MkdirAll(root, 0755); err != nil {
return nil, err
}
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
// 递归遍历子目录filepath.WalkDir知识库常按分类分文件夹组织
// (如 01_产品与设备/、03_售后支持/01_故障排查/),与素材扫描保持一致。
// 仅扫根目录会漏掉所有子目录文件,导致索引为空、向量召回失败。
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // 单个条目出错跳过,不中断整体重建
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if !isRootKnowledgeFile(entry.Name(), ext, allowed, cfg.Knowledge.IndexPath, cfg.Retrieval.EmbeddingIndexPath) {
continue
if d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(d.Name()))
if !isRootKnowledgeFile(d.Name(), ext, allowed, cfg.Knowledge.IndexPath, cfg.Retrieval.EmbeddingIndexPath) {
return nil
}
path := filepath.Join(root, entry.Name())
chunks, err := parseKnowledgeFile(path, root)
if err != nil {
var warning knowledgeParseWarning
@@ -133,15 +135,19 @@ func (e *AutoReplyEngine) rebuildKnowledgeIndex() (*KnowledgeIndex, error) {
}
} else {
idx.FailedFiles = append(idx.FailedFiles, fmt.Sprintf("%s: %v", path, err))
continue
return nil
}
}
if len(chunks) == 0 {
idx.FailedFiles = append(idx.FailedFiles, fmt.Sprintf("%s: 未读取到可索引内容", path))
continue
return nil
}
idx.FileCount++
idx.Chunks = append(idx.Chunks, chunks...)
return nil
})
if walkErr != nil {
return nil, walkErr
}
idx.LastIndexedAt = time.Now().Unix()
indexPath := resolveAutoReplyPath(cfg.Knowledge.IndexPath)

View File

@@ -71,6 +71,53 @@ func TestRebuildKnowledgeIndexCountsOnlyRootKnowledgeFiles(t *testing.T) {
}
}
// TestRebuildKnowledgeIndexScansSubdirectories 锁住递归扫描行为:
// 知识库按分类分文件夹组织时(文件在子目录里),重建必须把子目录里的文件
// 一并索引。这是“重置索引后向量仍为空”那个问题的根因回归测试。
func TestRebuildKnowledgeIndexScansSubdirectories(t *testing.T) {
dir := t.TempDir()
// 根目录故意不放任何知识文件,全部放进多层子目录。
files := map[string]string{
filepath.Join("01_产品", "数控机床", "VMC850规格.md"): "VMC850 立式加工中心,主轴转速 8000rpm。",
filepath.Join("03_售后", "故障排查", "常见故障.md"): "报警 E01 表示伺服过载,请检查负载。",
filepath.Join("readme.txt"): "", // 空文件,应进 FailedFiles 不计入 FileCount
}
for rel, content := range files {
full := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
t.Fatalf("mkdir for %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
cfg := config.NewDefaultAutoReplyConfig()
cfg.Knowledge.Directory = dir
cfg.Knowledge.IndexPath = filepath.Join(dir, "index.json")
cfg.Retrieval.EmbeddingIndexPath = filepath.Join(dir, "embedding_index.json")
engine := testAutoReplyEngine(cfg)
idx, err := engine.rebuildKnowledgeIndex()
if err != nil {
t.Fatalf("rebuildKnowledgeIndex failed: %v", err)
}
if idx.FileCount != 2 {
t.Fatalf("expected 2 indexed files from subdirectories, got %d (chunks=%d failed=%v)", idx.FileCount, len(idx.Chunks), idx.FailedFiles)
}
if len(idx.Chunks) == 0 {
t.Fatal("expected chunks from subdirectory files, got none")
}
// 确认子目录文件的相对路径作为 Source 被正确记录(用 / 分隔)。
sources := make(map[string]bool)
for _, chunk := range idx.Chunks {
sources[chunk.Source] = true
}
if !sources["01_产品/数控机床/VMC850规格.md"] {
t.Fatalf("expected nested source path recorded, got sources=%v", sources)
}
}
func TestParsePDFKnowledgeFileExtractsTextLayer(t *testing.T) {
path := filepath.Join(t.TempDir(), "text.pdf")
writeMinimalTextPDF(t, path, "AgentBox PDF content 123")

View File

@@ -0,0 +1,221 @@
package main
import (
"fmt"
"path/filepath"
"strings"
"sync"
"qiweimanager/config"
)
// materialCaptionGenerator 返回引擎当前可用的描述生成器。
// 未配置 AIBaseURL/Model 为空)时返回 nil同步时整体跳过生成
// 素材沿用按类型的默认话术,不影响原有行为。
func (e *AutoReplyEngine) materialCaptionGenerator() materialCaptionGenerator {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
return nil
}
aiCfg := cfg.AI
provider := strings.ToLower(strings.TrimSpace(cfg.AI.Provider))
return func(material AutoReplyMaterial, absPath string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(material.MaterialType)) {
case "image", "gif":
// 本地图片喂 vision 模型“看图说话”ollama 的多模态格式不同,退回按标题生成。
if provider == "local" || provider == "ollama" {
return generateMaterialCaptionByChat(aiCfg, provider, materialCaptionTitleUserPrompt(material))
}
return generateMaterialCaptionFromImage(aiCfg, material, absPath)
case "video":
// 视频不便直接喂模型,用标题让 chat 模型生成。
return generateMaterialCaptionByChat(aiCfg, provider, materialCaptionTitleUserPrompt(material))
default:
// 文档/表格:抽取开头文字喂 chat 模型概括;抽不出就退回按标题生成。
excerpt := materialDocumentExcerpt(absPath)
return generateMaterialCaptionByChat(aiCfg, provider, materialCaptionDocumentUserPrompt(material, excerpt))
}
}
}
// applyMaterialCaptions 为需要的素材并发生成开场白,原地写回 synced。
// 每个 goroutine 只写各自下标,互不重叠,无需加锁。
func applyMaterialCaptions(materials []AutoReplyMaterial, root string, generate materialCaptionGenerator) {
targets := make([]int, 0, len(materials))
for i := range materials {
if materialNeedsCaptionGeneration(materials[i]) {
targets = append(targets, i)
}
}
if len(targets) == 0 {
return
}
const maxConcurrent = 3
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for _, idx := range targets {
idx := idx
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
absPath := resolveAutoReplyMaterialPath(root, materials[idx].Path)
if caption, ok := generate(materials[idx], absPath); ok {
materials[idx].Caption = caption
materials[idx].CaptionSource = "ai"
}
}()
}
wg.Wait()
}
// materialNeedsCaptionGeneration 判断某条素材是否需要(重新)生成开场白。
// - manual运营手写绝不覆盖。
// - ai已生成过避免每次同步重复花费 token需要刷新走专门入口。
// - 其它caption 为空或仍是按类型的通用默认话术时才生成;
// 运营在 JSON 里手填的非通用 captionsource 为空)视为人工,保留不动。
func materialNeedsCaptionGeneration(material AutoReplyMaterial) bool {
switch strings.ToLower(strings.TrimSpace(material.CaptionSource)) {
case "manual", "ai":
return false
}
caption := strings.TrimSpace(material.Caption)
return caption == "" || isGenericMaterialCaption(caption)
}
// isGenericMaterialCaption 判断 caption 是否为系统内置的通用默认话术(含历史版本)。
func isGenericMaterialCaption(caption string) bool {
if isLegacyGenericMaterialCaption(caption) {
return true
}
norm := normalizeGreetingText(caption)
if norm == "" {
return true
}
for _, materialType := range []string{"image", "video", "gif", "file"} {
if normalizeGreetingText(defaultMaterialCaption(materialType)) == norm {
return true
}
}
return false
}
func generateMaterialCaptionFromImage(aiCfg config.AIConfig, material AutoReplyMaterial, absPath string) (string, bool) {
dataURL, err := imageDataURLFromFile(absPath)
if err != nil {
return "", false
}
result, err := callOpenAICompatibleVisionChat(aiCfg, materialCaptionSystemPrompt(), materialCaptionImageUserPrompt(material), dataURL)
if err != nil || result == nil {
return "", false
}
return sanitizeMaterialCaption(result.Answer)
}
func generateMaterialCaptionByChat(aiCfg config.AIConfig, provider string, userPrompt string) (string, bool) {
var (
result *AIResult
err error
)
if provider == "local" || provider == "ollama" {
result, err = callOllamaChat(aiCfg, materialCaptionSystemPrompt(), userPrompt)
} else {
result, err = callOpenAICompatibleChat(aiCfg, materialCaptionSystemPrompt(), userPrompt)
}
if err != nil || result == nil {
return "", false
}
return sanitizeMaterialCaption(result.Answer)
}
func materialCaptionSystemPrompt() string {
return "你是企业微信里的真人客服,现在要把一份资料顺手发给客户。请写一句自然口语的开场白," +
"要求①只有一句话不超过40字②像微信里随手发东西时说的话亲切自然不要书面腔和客服模板腔" +
"(不要用“您好”“为您提供”“请查收”这类);③结合资料内容点出这是什么、对客户有什么用;" +
"④不要编造资料里没有的信息;⑤只输出这句话本身,不要加引号、解释或多余标点。"
}
func materialCaptionImageUserPrompt(material AutoReplyMaterial) string {
return fmt.Sprintf("这是一张要发给客户的图片,标题是「%s」。请先看图片实际内容再写一句发图时的自然开场白。", strings.TrimSpace(material.Title))
}
func materialCaptionDocumentUserPrompt(material AutoReplyMaterial, excerpt string) string {
title := strings.TrimSpace(material.Title)
label := materialTypeLabel(material.MaterialType)
excerpt = strings.TrimSpace(excerpt)
if excerpt == "" {
return fmt.Sprintf("这是一份要发给客户的%s标题是「%s」。请根据标题写一句发送时的自然开场白。", label, title)
}
return fmt.Sprintf("这是一份要发给客户的%s标题是「%s」。以下是它开头部分的内容节选\n%s\n请结合内容写一句发送时的自然开场白。", label, title, excerpt)
}
func materialCaptionTitleUserPrompt(material AutoReplyMaterial) string {
return fmt.Sprintf("这是一个要发给客户的%s标题是「%s」。请根据标题写一句发送时的自然开场白。", materialTypeLabel(material.MaterialType), strings.TrimSpace(material.Title))
}
func materialTypeLabel(materialType string) string {
switch strings.ToLower(strings.TrimSpace(materialType)) {
case "image":
return "图片"
case "video":
return "视频"
case "gif":
return "动图"
default:
return "文件"
}
}
// materialDocumentExcerpt 复用知识库解析器抽取文档开头文字,供模型概括。
// 不支持的格式(如 .pptx会解析失败返回空串调用方退回按标题生成。
func materialDocumentExcerpt(absPath string) string {
chunks, err := parseKnowledgeFile(absPath, filepath.Dir(absPath))
if err != nil || len(chunks) == 0 {
return ""
}
var builder strings.Builder
for _, chunk := range chunks {
title := strings.TrimSpace(chunk.Title)
content := strings.TrimSpace(chunk.Content)
if title != "" {
if builder.Len() > 0 {
builder.WriteString("\n")
}
builder.WriteString(title)
}
if content != "" {
if builder.Len() > 0 {
builder.WriteString(" ")
}
builder.WriteString(content)
}
if len([]rune(builder.String())) >= 600 {
break
}
}
return truncateText(strings.TrimSpace(builder.String()), 800)
}
// sanitizeMaterialCaption 清洗模型输出:去包裹引号、压成单行、挡掉异常输出,限制长度。
func sanitizeMaterialCaption(raw string) (string, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return "", false
}
text = strings.Trim(text, "\"'“”‘’ \t\r\n")
text = strings.Join(strings.Fields(text), " ")
if text == "" {
return "", false
}
if strings.Contains(strings.ToUpper(text), "NO_ANSWER") {
return "", false
}
if runes := []rune(text); len(runes) > 60 {
text = strings.TrimSpace(string(runes[:60]))
}
if text == "" {
return "", false
}
return text, true
}

View File

@@ -21,8 +21,13 @@ type AutoReplyMaterial struct {
MaterialType string `json:"materialType"`
Path string `json:"path"`
Caption string `json:"caption"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
// CaptionSource 标记 caption 的来源:
// "ai" —— 同步时由模型自动生成;重新同步可被再次刷新。
// "manual" —— 运营手工编写;重新同步绝不覆盖。
// "" —— 未知/历史数据;按需生成。
CaptionSource string `json:"captionSource,omitempty"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
}
type autoReplyMaterialsFile struct {
@@ -93,8 +98,10 @@ func (e *AutoReplyEngine) collectMaterialMatches(materials []AutoReplyMaterial,
continue
}
path := resolveAutoReplyMaterialPath(root, material.Path)
score := materialMatchScore(searchText, material, hasSendIntent)
if score <= 0 {
score, strong := materialMatchScoreDetailed(searchText, material, hasSendIntent)
// 必须命中过强信号(整词关键词/问句模板,或整串标题/文件名)才算候选;
// 仅靠 2-gram 模糊片段凑分的弱命中直接丢弃,避免误发。
if score <= 0 || !strong {
continue
}
if _, err := os.Stat(path); err != nil {
@@ -211,12 +218,16 @@ func loadAutoReplyMaterials(indexPath string) ([]AutoReplyMaterial, error) {
return normalizeAutoReplyMaterials(list), nil
}
// materialCaptionGenerator 根据素材本身(含已解析的绝对路径)生成一句开场白。
// 返回 ok=false 表示本条生成失败/跳过,调用方应保留原 caption 不动。
type materialCaptionGenerator func(material AutoReplyMaterial, absPath string) (caption string, ok bool)
func (e *AutoReplyEngine) syncAutoReplyMaterials() (autoReplyMaterialSyncResult, error) {
cfg := e.getConfig()
return syncAutoReplyMaterials(cfg.Materials.Directory, cfg.Materials.IndexPath)
return syncAutoReplyMaterials(cfg.Materials.Directory, cfg.Materials.IndexPath, e.materialCaptionGenerator())
}
func syncAutoReplyMaterials(root string, indexPath string) (autoReplyMaterialSyncResult, error) {
func syncAutoReplyMaterials(root string, indexPath string, generateCaption materialCaptionGenerator) (autoReplyMaterialSyncResult, error) {
result := autoReplyMaterialSyncResult{
Directory: resolveAutoReplyPath(root),
IndexPath: resolveAutoReplyPath(indexPath),
@@ -270,6 +281,11 @@ func syncAutoReplyMaterials(root string, indexPath string) (autoReplyMaterialSyn
return strings.ToLower(synced[i].Title) < strings.ToLower(synced[j].Title)
})
// 在写盘前为需要的素材生成开场白generateCaption 为 nil如未配置 AI 或单测)时整体跳过。
if generateCaption != nil {
applyMaterialCaptions(synced, result.Directory, generateCaption)
}
if err := os.MkdirAll(filepath.Dir(result.IndexPath), 0755); err != nil {
return result, err
}
@@ -435,7 +451,17 @@ func normalizeAutoReplyMaterials(items []AutoReplyMaterial) []AutoReplyMaterial
}
func materialMatchScore(searchText string, material AutoReplyMaterial, hasSendIntent bool) int {
score, _ := materialMatchScoreDetailed(searchText, material, hasSendIntent)
return score
}
// materialMatchScoreDetailed 在打分之外额外返回 strong是否命中过“强信号”。
// 强信号 = 整词关键词/问句模板命中,或整串标题/文件名命中。
// 仅靠 2-gram 模糊片段fuzzyMaterialTokenScore凑出的分数不算强信号——
// 这类弱命中只用于在多个强匹配之间排序,不能单独触发发送,避免误发。
func materialMatchScoreDetailed(searchText string, material AutoReplyMaterial, hasSendIntent bool) (int, bool) {
score := 0
strong := false
for _, keyword := range append(material.Keywords, material.QuestionPatterns...) {
keyword = strings.ToLower(strings.TrimSpace(keyword))
if keyword == "" || isGenericMaterialIntentToken(keyword) {
@@ -443,19 +469,21 @@ func materialMatchScore(searchText string, material AutoReplyMaterial, hasSendIn
}
if strings.Contains(searchText, keyword) {
score += 10
strong = true
}
}
for _, field := range []string{material.Title, filepath.Base(material.Path), strings.TrimSuffix(filepath.Base(material.Path), filepath.Ext(material.Path))} {
field = strings.ToLower(strings.TrimSpace(field))
if field != "" && strings.Contains(searchText, field) {
if field != "" && len([]rune(field)) >= 2 && strings.Contains(searchText, field) {
score += 4
strong = true
}
score += fuzzyMaterialTokenScore(searchText, field)
}
if hasSendIntent && score > 0 {
score += 3
}
return score
return score, strong
}
func isBroadAllMaterialRequest(query string) bool {

View File

@@ -88,10 +88,43 @@ func (e *AutoReplyEngine) loadEmbeddingIndex() error {
if idx.Entries == nil {
idx.Entries = make(map[string]EmbeddingEntry)
}
// 防止"陈旧向量空间"问题:磁盘上的索引若与当前 embedding 模型/维度不一致,
// 它处于不同的向量空间余弦相似度会得到无意义的分数cosineSimilarity 还会静默截断长度)。
// 此时清空向量条目(保留模型/维度元信息),让检索自动回退到关键词,直到用户重建索引。
if mismatch := embeddingIndexStaleReason(idx, cfg.Retrieval); mismatch != "" && len(idx.Entries) > 0 {
if globalLogger != nil {
globalLogger.Warn("[Embedding] 索引与当前配置不一致(%s),已忽略陈旧向量并回退关键词检索,请重建知识库索引", mismatch)
}
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, "Embedding 索引与当前模型/维度不一致("+mismatch+"),已回退关键词检索,请重建知识库索引")
idx.Entries = make(map[string]EmbeddingEntry)
idx.Model = strings.TrimSpace(cfg.Retrieval.EmbeddingModel)
idx.Dimensions = cfg.Retrieval.EmbeddingDimensions
}
e.updateEmbeddingStatus(&idx)
return nil
}
// embeddingIndexStaleReason 返回磁盘索引与当前配置不一致的原因;一致则返回空字符串。
// 不区分具体平台DashScope / 万川 / 其它自建网关),只要模型名或维度对不上就视为陈旧向量空间。
func embeddingIndexStaleReason(idx EmbeddingIndex, retrievalCfg config.RetrievalConfig) string {
wantModel := strings.TrimSpace(retrievalCfg.EmbeddingModel)
gotModel := strings.TrimSpace(idx.Model)
if wantModel != "" {
if gotModel == "" {
// 旧版本生成的索引未记录模型名,无法证明它与当前模型同属一个向量空间,按陈旧处理。
return fmt.Sprintf("模型 (未知)→%s", wantModel)
}
if !strings.EqualFold(wantModel, gotModel) {
return fmt.Sprintf("模型 %s→%s", gotModel, wantModel)
}
}
wantDim := retrievalCfg.EmbeddingDimensions
if wantDim > 0 && idx.Dimensions > 0 && wantDim != idx.Dimensions {
return fmt.Sprintf("维度 %d→%d", idx.Dimensions, wantDim)
}
return ""
}
func (e *AutoReplyEngine) updateEmbeddingStatus(idx *EmbeddingIndex) {
if idx == nil {
idx = NewEmbeddingIndex("", 0)
@@ -107,9 +140,10 @@ func (e *AutoReplyEngine) updateEmbeddingStatus(idx *EmbeddingIndex) {
func (e *AutoReplyEngine) rebuildEmbeddingIndex(idx *KnowledgeIndex) error {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.APIKey) == "" || strings.TrimSpace(cfg.AI.BaseURL) == "" {
embedCfg := embeddingRequestConfig(cfg.AI, cfg.Retrieval)
if strings.TrimSpace(embedCfg.APIKey) == "" || strings.TrimSpace(embedCfg.BaseURL) == "" {
e.updateEmbeddingStatus(NewEmbeddingIndex(cfg.Retrieval.EmbeddingModel, cfg.Retrieval.EmbeddingDimensions))
return fmt.Errorf("Embedding索引跳过AI Base URL 或 API Key 未配置")
return fmt.Errorf("Embedding索引跳过Embedding/AI Base URL 或 API Key 未配置")
}
if idx == nil {
return nil
@@ -455,8 +489,9 @@ func (e *AutoReplyEngine) searchVectorKnowledge(query string, limit int) ([]Know
if idx == nil || embeddingIndex == nil || len(embeddingIndex.Entries) == 0 {
return nil, fmt.Errorf("向量索引为空,请先重建知识库索引")
}
if strings.TrimSpace(cfg.AI.APIKey) == "" || strings.TrimSpace(cfg.AI.BaseURL) == "" {
return nil, fmt.Errorf("AI Base URL 或 API Key 未配置")
embedCfg := embeddingRequestConfig(cfg.AI, cfg.Retrieval)
if strings.TrimSpace(embedCfg.APIKey) == "" || strings.TrimSpace(embedCfg.BaseURL) == "" {
return nil, fmt.Errorf("Embedding/AI Base URL 或 API Key 未配置")
}
vectors, err := callDashScopeEmbeddings(cfg.AI, cfg.Retrieval, []string{query})
if err != nil {
@@ -492,7 +527,8 @@ func callDashScopeEmbeddings(aiCfg config.AIConfig, retrievalCfg config.Retrieva
if len(inputs) == 0 {
return nil, nil
}
url := strings.TrimRight(aiCfg.BaseURL, "/")
embedCfg := embeddingRequestConfig(aiCfg, retrievalCfg)
url := strings.TrimRight(embedCfg.BaseURL, "/")
if !strings.HasSuffix(url, "/embeddings") {
url += "/embeddings"
}
@@ -511,7 +547,7 @@ func callDashScopeEmbeddings(aiCfg config.AIConfig, retrievalCfg config.Retrieva
} `json:"data"`
Error interface{} `json:"error"`
}
if err := doRetrievalJSONRequest(aiCfg, url, payload, &response); err != nil {
if err := doRetrievalJSONRequest(embedCfg, url, payload, &response); err != nil {
// 检测是否是模型配置错误
errMsg := err.Error()
if strings.Contains(strings.ToLower(errMsg), "unsupported model") &&
@@ -562,8 +598,9 @@ func callDashScopeRerank(aiCfg config.AIConfig, retrievalCfg config.RetrievalCon
Error interface{} `json:"error"`
}
var lastErr error
for _, url := range dashScopeRerankURLs(aiCfg) {
if err := doRetrievalJSONRequest(aiCfg, url, payload, &response); err != nil {
rerankCfg := rerankRequestConfig(aiCfg, retrievalCfg)
for _, url := range dashScopeRerankURLs(rerankCfg) {
if err := doRetrievalJSONRequest(rerankCfg, url, payload, &response); err != nil {
lastErr = err
continue
}
@@ -629,6 +666,34 @@ func doRetrievalJSONRequest(aiCfg config.AIConfig, url string, payload interface
return nil
}
// embeddingRequestConfig 返回用于向量调用的有效配置:
// 优先使用 Retrieval.EmbeddingBaseURL / EmbeddingAPIKey未配置则回退到聊天网关 AI.BaseURL / APIKey。
func embeddingRequestConfig(aiCfg config.AIConfig, retrievalCfg config.RetrievalConfig) config.AIConfig {
embedCfg := aiCfg
if base := strings.TrimSpace(retrievalCfg.EmbeddingBaseURL); base != "" {
embedCfg.BaseURL = base
}
key := strings.TrimSpace(retrievalCfg.EmbeddingAPIKey)
if key != "" && !looksLikeURL(key) {
embedCfg.APIKey = key
}
return embedCfg
}
// rerankRequestConfig 返回用于重排调用的有效配置:
// 优先使用 Retrieval.RerankBaseURL / RerankAPIKey未配置则回退到聊天网关 AI.BaseURL / APIKey。
func rerankRequestConfig(aiCfg config.AIConfig, retrievalCfg config.RetrievalConfig) config.AIConfig {
rerankCfg := aiCfg
if base := strings.TrimSpace(retrievalCfg.RerankBaseURL); base != "" {
rerankCfg.BaseURL = base
}
key := strings.TrimSpace(retrievalCfg.RerankAPIKey)
if key != "" && !looksLikeURL(key) {
rerankCfg.APIKey = key
}
return rerankCfg
}
func dashScopeRerankURLs(aiCfg config.AIConfig) []string {
baseURL := strings.TrimRight(aiCfg.BaseURL, "/")
if strings.Contains(baseURL, "dashscope.aliyuncs.com") {

View File

@@ -473,7 +473,7 @@ func TestSyncAutoReplyMaterialsAddsRemovesAndKeepsExistingConfig(t *testing.T) {
t.Fatalf("write existing index: %v", err)
}
result, err := syncAutoReplyMaterials(dir, indexPath)
result, err := syncAutoReplyMaterials(dir, indexPath, nil)
if err != nil {
t.Fatalf("syncAutoReplyMaterials failed: %v", err)
}
@@ -847,6 +847,214 @@ func TestSpecificMaterialRequestSendsOnlyBestMatch(t *testing.T) {
}
}
// TestFuzzyOnlyMatchDoesNotSendMaterial 锁住“强信号门槛”:
// 客户问句只与素材长标题切出的 2-gram 片段(如“数字”)模糊相交,
// 没有整词关键词/整串标题命中时,不应误发素材。
func TestFuzzyOnlyMatchDoesNotSendMaterial(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "企业级AI数字员工宣传手册.pptx"), []byte("file"), 0644); err != nil {
t.Fatalf("write material: %v", err)
}
indexPath := filepath.Join(dir, "materials.json")
materials := autoReplyMaterialsFile{Materials: []AutoReplyMaterial{{
ID: "ai-worker-brochure",
Title: "企业级AI数字员工宣传手册",
Keywords: []string{"企业级AI数字员工", "AI数字员工", "宣传手册"},
MaterialType: "file",
Path: "企业级AI数字员工宣传手册.pptx",
Enabled: true,
}}}
data, err := json.Marshal(materials)
if err != nil {
t.Fatalf("marshal materials: %v", err)
}
if err := os.WriteFile(indexPath, data, 0644); err != nil {
t.Fatalf("write materials index: %v", err)
}
cfg := config.NewDefaultAutoReplyConfig()
cfg.Materials.Directory = dir
cfg.Materials.IndexPath = indexPath
cfg.Materials.MaxPerReply = 2
engine := testAutoReplyEngine(cfg)
// “数字证书”与标题只在“数字”这个 2-gram 上相交,属于弱命中,应被门槛挡掉。
if matches := engine.matchMaterials("发我数字证书的资料", "发我数字证书的资料", nil); len(matches) != 0 {
t.Fatalf("expected fuzzy-only match to be rejected, got %#v", matches)
}
}
func TestMaterialNeedsCaptionGeneration(t *testing.T) {
cases := []struct {
name string
material AutoReplyMaterial
want bool
}{
{"manual never regenerated", AutoReplyMaterial{Caption: "随手写的", CaptionSource: "manual"}, false},
{"ai not regenerated", AutoReplyMaterial{Caption: "已生成", CaptionSource: "ai"}, false},
{"empty caption needs", AutoReplyMaterial{MaterialType: "file"}, true},
{"typed default needs", AutoReplyMaterial{Caption: defaultMaterialCaption("image"), MaterialType: "image"}, true},
{"legacy generic needs", AutoReplyMaterial{Caption: "我把相关资料直接发你。"}, true},
{"hand-written kept", AutoReplyMaterial{Caption: "这是AgentBox产线实拍图您看下整体布局"}, false},
}
for _, tc := range cases {
if got := materialNeedsCaptionGeneration(tc.material); got != tc.want {
t.Fatalf("%s: materialNeedsCaptionGeneration = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestSanitizeMaterialCaption(t *testing.T) {
if got, ok := sanitizeMaterialCaption(" “这是产品图,您看下~” "); !ok || got != "这是产品图,您看下~" {
t.Fatalf("expected quotes/space stripped, got %q ok=%v", got, ok)
}
if got, ok := sanitizeMaterialCaption("第一行\n第二行"); !ok || got != "第一行 第二行" {
t.Fatalf("expected newline collapsed to single line, got %q ok=%v", got, ok)
}
if _, ok := sanitizeMaterialCaption(" "); ok {
t.Fatal("expected blank input to be rejected")
}
if _, ok := sanitizeMaterialCaption("NO_ANSWER"); ok {
t.Fatal("expected NO_ANSWER token to be rejected")
}
long := strings.Repeat("描述", 50)
got, ok := sanitizeMaterialCaption(long)
if !ok || len([]rune(got)) > 60 {
t.Fatalf("expected long caption truncated to <=60 runes, got %d runes ok=%v", len([]rune(got)), ok)
}
}
func TestApplyMaterialCaptionsOnlyFillsTargets(t *testing.T) {
materials := []AutoReplyMaterial{
{Path: "a.jpg", MaterialType: "image", Caption: defaultMaterialCaption("image")},
{Path: "b.jpg", MaterialType: "image", Caption: "运营手写不能动", CaptionSource: "manual"},
{Path: "c.jpg", MaterialType: "image", Caption: "上次生成的", CaptionSource: "ai"},
}
calls := 0
generate := func(material AutoReplyMaterial, absPath string) (string, bool) {
calls++
return "生成给-" + material.Path, true
}
applyMaterialCaptions(materials, t.TempDir(), generate)
if calls != 1 {
t.Fatalf("expected generator called once (only the default-caption item), got %d", calls)
}
if materials[0].Caption != "生成给-a.jpg" || materials[0].CaptionSource != "ai" {
t.Fatalf("expected a.jpg regenerated and marked ai, got %#v", materials[0])
}
if materials[1].Caption != "运营手写不能动" || materials[1].CaptionSource != "manual" {
t.Fatalf("manual caption must be preserved, got %#v", materials[1])
}
if materials[2].Caption != "上次生成的" {
t.Fatalf("existing ai caption must not be regenerated, got %#v", materials[2])
}
}
func TestApplyMaterialCaptionsKeepsOriginalOnFailure(t *testing.T) {
materials := []AutoReplyMaterial{
{Path: "a.jpg", MaterialType: "image", Caption: defaultMaterialCaption("image")},
}
generate := func(material AutoReplyMaterial, absPath string) (string, bool) {
return "", false // 模拟生成失败
}
applyMaterialCaptions(materials, t.TempDir(), generate)
if materials[0].Caption != defaultMaterialCaption("image") || materials[0].CaptionSource != "" {
t.Fatalf("expected failed generation to leave caption untouched, got %#v", materials[0])
}
}
// TestSyncAutoReplyMaterialsGeneratesCaptionsEndToEnd 串起整条同步链路mock 生成器,不调真实 AI
// 真实落盘文件 → 扫描发现 → 经过生成器 → 写回 materials.json → 重新加载校验。
func TestSyncAutoReplyMaterialsGeneratesCaptionsEndToEnd(t *testing.T) {
dir := t.TempDir()
// 新图片素材:无既有索引,应被生成器赋予描述。
if err := os.WriteFile(filepath.Join(dir, "产线实拍.jpg"), []byte("jpg"), 0644); err != nil {
t.Fatalf("write image material: %v", err)
}
// 运营手写 caption 的素材:必须原样保留,不被生成覆盖。
if err := os.WriteFile(filepath.Join(dir, "报价单.pdf"), []byte("pdf"), 0644); err != nil {
t.Fatalf("write manual material: %v", err)
}
indexPath := filepath.Join(dir, "materials.json")
existing := autoReplyMaterialsFile{Materials: []AutoReplyMaterial{{
ID: "manual-quote",
Title: "报价单",
Keywords: []string{"报价单", "报价"},
MaterialType: "file",
Path: "报价单.pdf",
Caption: "这是最新报价,您过下目~",
CaptionSource: "manual",
Priority: 5,
Enabled: true,
}}}
data, err := json.Marshal(existing)
if err != nil {
t.Fatalf("marshal existing: %v", err)
}
if err := os.WriteFile(indexPath, data, 0644); err != nil {
t.Fatalf("write existing index: %v", err)
}
// mock 生成器:记录被生成的素材路径,返回可识别的描述。
var generated []string
generate := func(material AutoReplyMaterial, absPath string) (string, bool) {
generated = append(generated, material.Path)
if _, statErr := os.Stat(absPath); statErr != nil {
t.Errorf("generator got unreadable absPath %q: %v", absPath, statErr)
}
return "看下这张「" + material.Title + "」~", true
}
result, err := syncAutoReplyMaterials(dir, indexPath, generate)
if err != nil {
t.Fatalf("sync failed: %v", err)
}
if result.Total != 2 {
t.Fatalf("expected 2 materials total, got %#v", result)
}
// 只有新图片应触发生成,手写素材不触发。
if len(generated) != 1 || generated[0] != "产线实拍.jpg" {
t.Fatalf("expected only the new image to be generated, got %#v", generated)
}
got, err := loadAutoReplyMaterials(indexPath)
if err != nil {
t.Fatalf("reload synced materials: %v", err)
}
byPath := make(map[string]AutoReplyMaterial, len(got))
for _, item := range got {
byPath[item.Path] = item
}
image, ok := byPath["产线实拍.jpg"]
if !ok {
t.Fatalf("image material missing after sync: %#v", got)
}
if image.Caption != "看下这张「产线实拍」~" || image.CaptionSource != "ai" {
t.Fatalf("expected generated caption marked ai, got %#v", image)
}
manual, ok := byPath["报价单.pdf"]
if !ok {
t.Fatalf("manual material missing after sync: %#v", got)
}
if manual.Caption != "这是最新报价,您过下目~" || manual.CaptionSource != "manual" {
t.Fatalf("manual caption must survive sync untouched, got %#v", manual)
}
// 再同步一次ai 描述已存在,不应重复调用生成器。
generated = nil
if _, err := syncAutoReplyMaterials(dir, indexPath, generate); err != nil {
t.Fatalf("second sync failed: %v", err)
}
if len(generated) != 0 {
t.Fatalf("expected no regeneration on second sync, got %#v", generated)
}
}
func TestPromptLeakageAnswerIsSanitized(t *testing.T) {
answer := "您好,我是企业微信智能客服。\n话语规则只用第一人称不要说本系统、本AI。你的目标是让客户感觉自己在和这家公司的人对话。根据知识库回答。"
cfg := config.NewDefaultAutoReplyConfig()
@@ -1585,6 +1793,64 @@ func TestContextualSearchTextIncludesRecentQuestion(t *testing.T) {
}
}
// 自包含问题(如“今天星期几”)不应把上一个话题的对话拼进检索 query
// 否则会把旧话题的知识高分召回,导致顺着旧话题继续答。
func TestContextualSearchTextSkipsContextForSelfContainedQuestion(t *testing.T) {
withTestContextCachePath(t)
cfg := config.NewDefaultAutoReplyConfig()
engine := testAutoReplyEngine(cfg)
prev := autoReplyMessage{ClientID: 7, RobotID: "robot-user", ConversationID: "S:robot-user_customer-user", FromWxID: "customer-user", Content: "IRB 1200是什么"}
engine.rememberUserMessage(prev)
engine.rememberAssistantMessage(prev, "IRB 1200是一款紧凑型6轴工业机器人重复定位精度±0.02mm。")
question := "今天星期几"
searchText := engine.contextualSearchText(question, autoReplyMessage{ClientID: 7, RobotID: "robot-user", ConversationID: "S:robot-user_customer-user", FromWxID: "customer-user", Content: question})
if searchText != question {
t.Fatalf("self-contained question should not carry previous topic into search, got %q", searchText)
}
if strings.Contains(searchText, "IRB") {
t.Fatalf("search text leaked previous topic: %q", searchText)
}
}
func TestQuestionReferencesContext(t *testing.T) {
cases := []struct {
question string
want bool
}{
{"它多少钱", true}, // 它多少钱
{"这个怎么用", true}, // 这个怎么用
{"刚才那个再说说", true}, // 刚才那个再说说
{"继续", true}, // 继续
{"今天星期几", false}, // 今天星期几
{"你们有什么产品", false}, // 你们有什么产品
{"换个话题吧", false}, // 换个话题吧
}
for _, c := range cases {
if got := questionReferencesContext(c.question); got != c.want {
t.Errorf("questionReferencesContext(%q)=%v, want %v", c.question, got, c.want)
}
}
}
func TestIsPureTopicSwitchMessage(t *testing.T) {
cases := []struct {
content string
want bool
}{
{"换个话题吧", true}, // 换个话题吧
{"我们聊点别的", true}, // 我们聊点别的
{"不聊这个了", true}, // 不聊这个了
{"换个话题,你们产品多少钱", false}, // 换个话题,你们产品多少钱(带了新问题)
{"今天星期几", false}, // 今天星期几
}
for _, c := range cases {
if got := isPureTopicSwitchMessage(c.content); got != c.want {
t.Errorf("isPureTopicSwitchMessage(%q)=%v, want %v", c.content, got, c.want)
}
}
}
func TestImageRecognitionContentEntersNormalReplyFlow(t *testing.T) {
withTestContextCachePath(t)
restoreClients := setTestIdentifiedClients(t, map[uint32]string{7: "robot-user"})
@@ -2363,8 +2629,8 @@ func TestFastAutoReplyDefaults(t *testing.T) {
if cfg.AI.MaxTokens != 700 {
t.Fatalf("expected 700 max tokens, got %d", cfg.AI.MaxTokens)
}
if cfg.AI.ReplyDetail != "detailed" {
t.Fatalf("expected detailed reply detail, got %s", cfg.AI.ReplyDetail)
if cfg.AI.ReplyDetail != "medium" {
t.Fatalf("expected medium reply detail, got %s", cfg.AI.ReplyDetail)
}
if cfg.AI.EnableThinking {
t.Fatal("expected thinking to be disabled by default")
@@ -3871,3 +4137,46 @@ func TestLongKnowledgeQueryFindsLateSectionByChinesePhrase(t *testing.T) {
t.Fatalf("expected neighbor section to be included, got %s", text)
}
}
// 磁盘索引与当前配置不一致(含旧版本无模型名的情况)应判为陈旧,触发清空向量回退关键词。
func TestEmbeddingIndexStaleReason(t *testing.T) {
cases := []struct {
name string
idx EmbeddingIndex
retrieval config.RetrievalConfig
wantStale bool
}{
{
name: "same model and dims",
idx: EmbeddingIndex{Model: "text-embedding-v4", Dimensions: 512},
retrieval: config.RetrievalConfig{EmbeddingModel: "text-embedding-v4", EmbeddingDimensions: 512},
wantStale: false,
},
{
name: "different model",
idx: EmbeddingIndex{Model: "text-embedding-v4", Dimensions: 512},
retrieval: config.RetrievalConfig{EmbeddingModel: "wanchuan-embed", EmbeddingDimensions: 512},
wantStale: true,
},
{
name: "legacy index without model name",
idx: EmbeddingIndex{Model: "", Dimensions: 0},
retrieval: config.RetrievalConfig{EmbeddingModel: "wanchuan-embed", EmbeddingDimensions: 1024},
wantStale: true,
},
{
name: "different dimensions same model",
idx: EmbeddingIndex{Model: "text-embedding-v4", Dimensions: 512},
retrieval: config.RetrievalConfig{EmbeddingModel: "text-embedding-v4", EmbeddingDimensions: 1024},
wantStale: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reason := embeddingIndexStaleReason(tc.idx, tc.retrieval)
if (reason != "") != tc.wantStale {
t.Fatalf("embeddingIndexStaleReason() = %q, wantStale=%v", reason, tc.wantStale)
}
})
}
}

85
text Normal file
View File

@@ -0,0 +1,85 @@
我:【这是什么啊;】
ai【pp 您好,看您问“这是什么”,结合咱们刚才聊的上下文,您指的应该就是 **IRB 1200** 这款紧凑型 6 轴工业机器人吧?
简单来说它是专门为在狭小空间里干高精度、高灵活性活儿设计的。因为重复定位精度能达到±0.02mm,负载能力也不错,所以在现代智能工厂里特别受欢迎,像上下料、装配、打磨去毛刺、检测还有包装这些场景都能胜任。
为了让您更清楚它的具体情况,我把它的核心参数、运动能力以及操作维护的重点给您详细展开说说:
**1. 规格型号与硬指标**
这款机器人主要有两个细分型号,您可以根据现场需求选:
* **型号区分**:分别是 **IRB 1200-5/0.9** 和 **IRB 1200-7/0.7**。
* **负载能力**:前者的额定负载是 5kg后者是 7kg最大负载都能到 7kg
* **工作范围**5kg 款的工作半径是 901mm7kg 款是 703mm。
* **自重与安装**:本体重量都是 25kg非常轻便。安装方式特别灵活地面、壁挂、倾斜甚至倒置安装都没问题。
* **防护等级**:标准版是 IP40如果您环境比较恶劣比如有铸造粉尘还有可选的 IP67“铸造专家版”。
* **控制系统与电源**:支持 IRC5 或者更先进的 OmniCore 控制器。电源要求是三相 AC 380V±10%),额定功率大概 1.5kW。
* **速度性能**:最大 TCP 速度可达 2.5 m/s标准周期时间1kg 负载下)≤ 0.38 秒,效率很高。
**2. 运动灵活性6 轴设计)**
它的动作非常舒展,适合复杂角度作业:
* **轴 1基座旋转** 和 **轴 4腕部旋转**:都能转 ±170°
* **轴 2大臂摆动**:范围 ±130°
* **轴 3小臂摆动**:范围 -70° 到 +80°
* **轴 5腕部摆动**:范围 ±120°
* **轴 6腕部翻转**:甚至能转 ±360°这点在处理复杂工件时特别有用。
**3. 操作与安全红线(非常重要)**
如果您打算操作或接触这台设备,有几点安全规定必须记牢:
* **人员资质**:操作、维护或编程的人员**必须经过专业培训并考核合格**,未经授权绝对不能进入工作区。
* **模式切换**:进入工作区域前,必须把机器人切到**手动模式**并关闭伺服。调试程序时,强烈建议先用 **T1 模式(减速手动)**,确认安全后再切自动模式。严禁在自动模式下打开安全门。
* **安全装置**:一定要用好示教器上的】
我:【换个话题把】
ai【哈哈pp 您这话题转得够快!刚才咱们正聊到 IRB 1200 的操作安全红线,特别是关于“安全装置”那块还没说完,我就顺着这点跟您展开讲讲吧,毕竟这可是保命的关键,马虎不得。
接上回说的,操作这台机器人时,**示教器上的“安全锁”(也就是常说的 Deadman switch一定要用好**。这是个防止误动作的保险,您手一松或者握得太紧,机器人都可能停住,确保只有在您完全掌控下它才动。
除了这个,还有几条硬性的安全规矩和操作细节,我得给您交代清楚:
**1. 进工作区前的“铁律”**
只要您打算进入机器人的工作区域,必须先把机器人切换到**手动模式**,并且把伺服电源关掉。千万别在自动模式下还开着安全门就进去,那是绝对禁止的。进去之前,还得确认围栏、安全门、光栅这些保护装置都是完好的,而且处于启用状态。
**2. 调试程序的正确姿势**
如果您是在调试新程序,强烈建议先用 **T1 模式(减速手动)** 跑一遍。这个模式下速度慢,就算有意外也容易反应。等确认逻辑没问题、周围安全了,再切到自动模式去批量生产。调试时还可以用“单步执行”功能,一行一行地检查动作和信号逻辑,稳当得很。
**3. 日常巡检不能少**
为了让设备长久稳定运行,有些检查是每天、每周甚至每月都要做的:
* **每班必查**:看看本体有没有磕碰、油污或螺丝松动;检查电缆和气管有没有破损、缠绕,接头紧不紧;听听关节运行时有没有异响。
* **每周维护**:给各轴的润滑点按周期加注润滑脂;摸摸减速机温度,正常运行不能超过 70℃。
* **每月/每半年**:每月清理一下控制柜滤网的灰尘;每半年检查一下编码器电池的电压,不行就得换,不然断电后位置数据可能会丢。
**4. 万一报警了咋办?**
要是屏幕上跳出报警代码,别慌,对照着处理就行:
* 如果是 **10017急停被按下** 或 **10013安全门打开**,那就复位急停按钮或者关好安全门,确认安全后再重新上电。
* 要是 **20032轴未校准**,就得重新执行校准程序。
* 遇到 **38001电机温度过高**,可能是负载太重或散热不好,试着降低速度或改善通风。
* 如果是 **50056关节速度超限**,检查一下程序里的速度设置,把倍率降下来。
这些细节虽然琐碎,但都是保证咱们工厂安全高效运行的基础。您看这部分内容够详细不?要是想了解怎么具体编写程序(比如用 RAPID 语言写指令、定义工具数据那些),我也可以接着给您唠唠!】
我:【今天星期几】
ai:【哈哈pp 您这话题转得够快!刚才咱们正聊到 IRB 1200 的操作安全红线,特别是关于“安全装置”那块还没说完,我就顺着这点跟您展开讲讲吧,毕竟这可是保命的关键,马虎不得。
接上回说的,操作这台机器人时,**示教器上的“安全锁”(也就是常说的 Deadman switch一定要用好**。这是个防止误动作的保险,您手一松或者握得太紧,机器人都可能停住,确保只有在您完全掌控下它才动。
除了这个,还有几条硬性的安全规矩和操作细节,我得给您交代清楚:
**1. 进工作区前的“铁律”**
只要您打算进入机器人的工作区域,必须先把机器人切换到**手动模式**,并且把伺服电源关掉。千万别在自动模式下还开着安全门就进去,那是绝对禁止的。进去之前,还得确认围栏、安全门、光栅这些保护装置都是完好的,而且处于启用状态。
**2. 调试程序的正确姿势**
如果您是在调试新程序,强烈建议先用 **T1 模式(减速手动)** 跑一遍。这个模式下速度慢,就算有意外也容易反应。等确认逻辑没问题、周围安全了,再切到自动模式去批量生产。调试时还可以用“单步执行”功能,一行一行地检查动作和信号逻辑,稳当得很。
**3. 日常巡检不能少**
为了让设备长久稳定运行,有些检查是每天、每周甚至每月都要做的:
* **每班必查**:看看本体有没有磕碰、油污或螺丝松动;检查电缆和气管有没有破损、缠绕,接头紧不紧;听听关节运行时有没有异响。
* **每周维护**:给各轴的润滑点按周期加注润滑脂;摸摸减速机温度,正常运行不能超过 70℃。
* **每月/每半年**:每月清理一下控制柜滤网的灰尘;每半年检查一下编码器电池的电压,不行就得换,不然断电后位置数据可能会丢。
**4. 万一报警了咋办?**
要是屏幕上跳出报警代码,别慌,对照着处理就行:
* 如果是 **10017急停被按下** 或 **10013安全门打开**,那就复位急停按钮或者关好安全门,确认安全后再重新上电。
* 要是 **20032轴未校准**,就得重新执行校准程序。
* 遇到 **38001电机温度过高**,可能是负载太重或散热不好,试着降低速度或改善通风。
* 如果是 **50056关节速度超限**,检查一下程序里的速度设置,把倍率降下来。
这些细节虽然琐碎,但都是保证咱们工厂安全高效运行的基础。您看这部分内容够详细不?要是想了解怎么具体编写程序(比如用 RAPID 语言写指令、定义工具数据那些),我也可以接着给您唠唠!】

242
wanchuan_proxy.go Normal file
View File

@@ -0,0 +1,242 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"qiweimanager/config"
)
// WanchuanLogin 登录万川平台并返回原始响应
func (a *App) WanchuanLogin(baseURL, username, password string) string {
startTime := time.Now()
// 构建请求体
loginData := map[string]interface{}{
"username": username,
"password": password,
"loginType": "user",
}
jsonData, err := json.Marshal(loginData)
if err != nil {
globalLogger.Error("[WanchuanLogin] 序列化登录数据失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "序列化登录数据失败: %v"}`, err)
}
// 打印日志(密码打码)
globalLogger.Info("[WanchuanLogin] 登录请求 - URL: %s/api/login, 用户名: %s, 密码: %s",
baseURL, username, maskString(password))
// 发送 POST 请求
url := fmt.Sprintf("%s/api/login", strings.TrimRight(baseURL, "/"))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
globalLogger.Error("[WanchuanLogin] 创建请求失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "创建请求失败: %v"}`, err)
}
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
globalLogger.Error("[WanchuanLogin] 请求失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "请求失败: %v"}`, err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
globalLogger.Error("[WanchuanLogin] 读取响应失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "读取响应失败: %v"}`, err)
}
responseStr := string(body)
// 打印日志token 打码)
maskedResponse := maskToken(responseStr)
duration := time.Since(startTime).Milliseconds()
globalLogger.Info("[WanchuanLogin] 登录完成 - 耗时: %dms, 响应: %s", duration, maskedResponse)
a.AddLogEntry("Wanchuan", "info", fmt.Sprintf("平台登录完成 - 用户: %s", username), duration)
// 原样返回平台响应
return responseStr
}
// WanchuanGetModel 获取模型配置并返回原始响应
func (a *App) WanchuanGetModel(baseURL, code, token string) string {
startTime := time.Now()
globalLogger.Info("[WanchuanGetModel] 获取模型 - URL: %s/api/system/model/getByCode/%s, Token: %s",
baseURL, code, maskString(token))
// 发送 GET 请求
url := fmt.Sprintf("%s/api/system/model/getByCode/%s", strings.TrimRight(baseURL, "/"), code)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
globalLogger.Error("[WanchuanGetModel] 创建请求失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "创建请求失败: %v"}`, err)
}
// 设置认证头(平台同时支持两种)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("token", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
globalLogger.Error("[WanchuanGetModel] 请求失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "请求失败: %v"}`, err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
globalLogger.Error("[WanchuanGetModel] 读取响应失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "读取响应失败: %v"}`, err)
}
responseStr := string(body)
// 打印日志apiKey 打码)
maskedResponse := maskApiKey(responseStr)
duration := time.Since(startTime).Milliseconds()
globalLogger.Info("[WanchuanGetModel] 获取模型完成 - code: %s, 耗时: %dms, 响应: %s", code, duration, maskedResponse)
a.AddLogEntry("Wanchuan", "info", fmt.Sprintf("获取模型配置 - code: %s", code), duration)
// 原样返回平台响应
return responseStr
}
// GetPlatformConfig 获取平台配置
func (a *App) GetPlatformConfig() interface{} {
globalLogger.Info("[GetPlatformConfig] 读取平台配置")
appConfig := config.GetGlobalConfig()
if appConfig == nil {
globalLogger.Warn("[GetPlatformConfig] 全局配置不存在")
return config.PlatformConfig{}
}
return appConfig.PlatformConfig
}
// SavePlatformConfig 保存平台配置
func (a *App) SavePlatformConfig(jsonData string) (bool, string) {
startTime := time.Now()
globalLogger.Info("[SavePlatformConfig] 保存平台配置 - 数据: %s", maskPlatformConfig(jsonData))
var platformConfig config.PlatformConfig
if err := json.Unmarshal([]byte(jsonData), &platformConfig); err != nil {
msg := fmt.Sprintf("解析平台配置失败: %v", err)
globalLogger.Error("%s", msg)
return false, msg
}
if err := config.UpdatePlatformConfig(platformConfig); err != nil {
msg := fmt.Sprintf("保存平台配置失败: %v", err)
globalLogger.Error("%s", msg)
return false, msg
}
duration := time.Since(startTime).Milliseconds()
globalLogger.Info("[SavePlatformConfig] 保存成功 - 耗时: %dms", duration)
a.AddLogEntry("Wanchuan", "info", "平台配置已保存", duration)
return true, "success"
}
// maskString 对字符串打码(保留首尾,中间用 * 替换)
func maskString(s string) string {
if len(s) <= 8 {
return "***"
}
return s[:4] + "****" + s[len(s)-4:]
}
// maskToken 对 JSON 响应中的 token 打码
func maskToken(jsonStr string) string {
var data map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return jsonStr
}
// 递归打码 token 字段
maskTokenInMap(data)
masked, _ := json.Marshal(data)
return string(masked)
}
// maskApiKey 对 JSON 响应中的 apiKey 打码
func maskApiKey(jsonStr string) string {
var data map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return jsonStr
}
// 递归打码 apiKey 字段
maskApiKeyInMap(data)
masked, _ := json.Marshal(data)
return string(masked)
}
// maskPlatformConfig 对平台配置中的密码打码
func maskPlatformConfig(jsonStr string) string {
var data map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return jsonStr
}
if pwd, ok := data["password"].(string); ok && pwd != "" {
data["password"] = maskString(pwd)
}
masked, _ := json.Marshal(data)
return string(masked)
}
func maskTokenInMap(m map[string]interface{}) {
for k, v := range m {
if k == "token" || k == "access_token" {
if s, ok := v.(string); ok && s != "" {
m[k] = maskString(s)
}
} else if subMap, ok := v.(map[string]interface{}); ok {
maskTokenInMap(subMap)
}
}
}
func maskApiKeyInMap(m map[string]interface{}) {
for k, v := range m {
if k == "apiKey" || k == "api_key" {
if s, ok := v.(string); ok && s != "" {
m[k] = maskString(s)
}
} else if k == "encryptedConfig" {
if s, ok := v.(string); ok && s != "" {
// encryptedConfig 是 JSON 字符串,需要二次解析
var configMap map[string]interface{}
if err := json.Unmarshal([]byte(s), &configMap); err == nil {
maskApiKeyInMap(configMap)
masked, _ := json.Marshal(configMap)
m[k] = string(masked)
}
}
} else if subMap, ok := v.(map[string]interface{}); ok {
maskApiKeyInMap(subMap)
}
}
}

View File

@@ -1,6 +1,6 @@
@echo off
chcp 65001 >nul
rem 一键本地开发:自动配置 Go/Wails 环境、安装依赖并启动 wails dev
cd /d "%~dp0"
rem Start local Wails development environment.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\dev.ps1" %*
pause

View File

@@ -1,7 +1,6 @@
@echo off
chcp 65001 >nul
rem 一键打包:编译 helper + 主程序,产出 dist\qiweimanager 绿色免安装版
rem 如需 NSIS 安装包,改为运行: 打包.bat -Installer (需先安装 NSIS
cd /d "%~dp0"
rem Build release package. Use: package.bat -Installer for NSIS installer.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build.ps1" %*
pause